Merge branch 'features/household-validation' into features/household-edit-members-forms-improve-household

This commit is contained in:
Julien Fastré 2021-06-17 22:46:47 +02:00
commit 9f3cd943cb
42 changed files with 1674 additions and 42 deletions

View File

@ -0,0 +1,10 @@
/*
* when an alert is the first child of the page, with a banner, we do not want the alert to be merged with the banner
*/
div.container.content {
& > div {
div.alert:nth-child(2) {
margin-top: 1rem;
}
}
}

View File

@ -0,0 +1,29 @@
div.alert.alert-with-actions {
display: flex;
flex-direction: row;
ul.record_actions {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
li:nth-child(1n+2) {
margin-top: 0.5rem;
}
li {
margin-right: 0;
}
}
@media screen and (max-width: 1050px) {
flex-direction: column;
ul.record_actions {
margin-top: 1rem;
text-align: center;
}
}
}

View File

@ -7,6 +7,10 @@
*/
@import 'alert-first-child';
@import 'alert-with-actions';
/* [hack] /!\ Contourne le positionnement problématique du div#content_conainter suivant,
* car sa position: relative le place au-dessus du bandeau et les liens sont incliquables */
div.subheader {

View File

@ -164,10 +164,12 @@
{{ encore_entry_script_tags('ckeditor5') }}
{% endif %}
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', function(e) {
chill.checkOtherValueOnChange();
$('.select2').select2({allowClear: true});
chill.categoryLinkParentChildSelect();
</script>
{% block js%}<!-- nothing added to js -->{% endblock %}
</body>
</html>
});
</script>
{% block js%}<!-- nothing added to js -->{% endblock %}
</body>
</html>

View File

@ -0,0 +1,216 @@
<?php
namespace Chill\MainBundle\Tests\Util;
use Chill\MainBundle\Util\DateRangeCovering;
use PHPUnit\Framework\TestCase;
class DateRangeCoveringTest extends TestCase
{
public function testCoveringWithMinCover1()
{
$cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1)
->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2)
->add(new \DateTime('2019-06-01'), new \DateTime('2019-06-01'), 3)
->compute()
;
$this->assertTrue($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(1, $cover->getIntersections());
$this->assertEquals(
new \DateTime('2010-06-01'),
$cover->getIntersections()[0][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2010-12-01'),
$cover->getIntersections()[0][1],
"assert date end are the intersection"
);
$this->assertIsArray($cover->getIntersections()[0][2]);
$this->assertContains(1, $cover->getIntersections()[0][2]);
$this->assertContains(2, $cover->getIntersections()[0][2]);
$this->assertNotContains(3, $cover->getIntersections()[0][2]);
}
public function testCoveringWithMinCover1WithTwoIntersections()
{
$cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1)
->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2)
->add(new \DateTime('2019-01-01'), new \DateTime('2019-12-01'), 3)
->add(new \DateTime('2019-06-01'), new \DateTime('2020-06-01'), 4)
->compute()
;
$this->assertTrue($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(2, $cover->getIntersections());
$intersections = $cover->getIntersections();
// sort the intersections to compare them in expected order
\usort($intersections, function($a, $b) {
if ($a[0] === $b[0]) {
return $a[1] <=> $b[1];
}
return $a[0] <=> $b[0];
});
// first intersection
$this->assertEquals(
new \DateTime('2010-06-01'),
$intersections[0][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2010-12-01'),
$intersections[0][1],
"assert date end are the intersection"
);
$this->assertIsArray($intersections[0][2]);
$this->assertContains(1, $intersections[0][2]);
$this->assertContains(2, $intersections[0][2]);
$this->assertNotContains(3, $intersections[0][2]);
$this->assertNotContains(4, $intersections[0][2]);
// second intersection
$this->assertEquals(
new \DateTime('2019-06-01'),
$intersections[1][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2019-12-01'),
$intersections[1][1],
"assert date end are the intersection"
);
$this->assertIsArray($intersections[1][2]);
$this->assertContains(3, $intersections[1][2]);
$this->assertContains(4, $intersections[1][2]);
$this->assertNotContains(1, $intersections[1][2]);
$this->assertNotContains(2, $intersections[1][2]);
}
public function testCoveringWithMinCover2()
{
$cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1)
->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2)
->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3)
->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4)
->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5)
->compute()
;
$this->assertTrue($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(1, $cover->getIntersections());
$this->assertEquals(
new \DateTime('2010-06-01'),
$cover->getIntersections()[0][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2010-09-01'),
$cover->getIntersections()[0][1],
"assert date end are the intersection"
);
$this->assertIsArray($cover->getIntersections()[0][2]);
$this->assertContains(1, $cover->getIntersections()[0][2]);
$this->assertContains(2, $cover->getIntersections()[0][2]);
$this->assertContains(3, $cover->getIntersections()[0][2]);
$this->assertNotContains(4, $cover->getIntersections()[0][2]);
$this->assertNotContains(5, $cover->getIntersections()[0][2]);
}
public function testCoveringWithMinCover2AndThreePeriodsCovering()
{
$cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1)
->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2)
->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3)
->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), 4)
->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 5)
->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 6)
->compute()
;
$this->assertTrue($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(1, $cover->getIntersections());
$this->assertEquals(
new \DateTime('2010-04-01'),
$cover->getIntersections()[0][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2010-09-15'),
$cover->getIntersections()[0][1],
"assert date end are the intersection"
);
$this->assertIsArray($cover->getIntersections()[0][2]);
$this->assertContains(1, $cover->getIntersections()[0][2]);
$this->assertContains(2, $cover->getIntersections()[0][2]);
$this->assertContains(3, $cover->getIntersections()[0][2]);
$this->assertContains(4, $cover->getIntersections()[0][2]);
$this->assertNotContains(5, $cover->getIntersections()[0][2]);
$this->assertNotContains(6, $cover->getIntersections()[0][2]);
}
public function testCoveringWithMinCover2AndThreePeriodsCoveringWithNullMetadata()
{
$cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null)
->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null)
->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null)
->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null)
->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null)
->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null)
->compute()
;
$this->assertTrue($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(1, $cover->getIntersections());
$this->assertEquals(
new \DateTime('2010-04-01'),
$cover->getIntersections()[0][0],
"assert date start are the intersection"
);
$this->assertEquals(
new \DateTime('2010-09-15'),
$cover->getIntersections()[0][1],
"assert date end are the intersection"
);
$this->assertIsArray($cover->getIntersections()[0][2]);
}
public function testCoveringWithMinCover3Absent()
{
$cover = new DateRangeCovering(3, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1)
->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2)
->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3)
->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4)
->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5)
->compute()
;
$this->assertFalse($cover->hasIntersections());
$this->assertIsArray($cover->getIntersections());
$this->assertCount(0, $cover->getIntersections());
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace Chill\MainBundle\Util;
/**
* Utilities to compare date periods
*
* This class allow to compare periods when there are period covering. The
* argument `minCovers` allow to find also when there are more than 2 period
* which intersects.
*
* Example: a team may have maximum 2 leaders on a same period: you will
* find here all periods where there are more than 2 leaders.
*
* Usage:
*
* ```php
* $cover = new DateRangeCovering(2); // 2 means we will have periods
* // when there are 2+ periods intersecting
* $cover
* ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null)
* ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null)
* ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null)
* ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null)
* ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null)
* ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null)
* ->compute()
* ;
* $cover->getIntersections();
* ```
*/
class DateRangeCovering
{
private bool $computed = false;
private array $intersections = [];
private array $intervals = [];
private int $minCover;
private int $uniqueKeyCounter = 0;
private array $metadatas = [];
private array $sequence = [];
private \DateTimeZone $tz;
/**
* @param int $minCover the minimum of covering required
*/
public function __construct(int $minCover, \DateTimeZone $tz)
{
if ($minCover < 0) {
throw new \LogicException("argument minCover cannot be lower than 0");
}
$this->minCover = $minCover;
$this->tz = $tz;
}
public function add(\DateTimeInterface $start, \DateTimeInterface $end = null, $metadata = null): self
{
if ($this->computed) {
throw new \LogicException("You cannot add intervals to a computed instance");
}
$k = $this->uniqueKeyCounter++;
$this->intervals[$k] = [$start, $end];
$this->metadatas[$k] = $metadata;
$this->addToSequence($start->getTimestamp(), $k, null);
$this->addToSequence(
NULL === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k
);
return $this;
}
private function addToSequence($timestamp, int $start = null, int $end = null)
{
if (!\array_key_exists($timestamp, $this->sequence)) {
$this->sequence[$timestamp] = [ 's' => [], 'e' => [] ];
}
if (NULL !== $start) {
$this->sequence[$timestamp]['s'][] = $start;
}
if (NULL !== $end) {
$this->sequence[$timestamp]['e'][] = $end;
}
}
public function compute(): self
{
\ksort($this->sequence);
$currentPeriod = [];
$currents = [];
$isOpen = false;
$overs = [];
foreach ($this->sequence as $ts => $moves) {
$currents = \array_merge($currents, $moves['s']);
$currents = \array_diff($currents, $moves['e']);
if (count($currents) > $this->minCover && !$isOpen) {
$currentPeriod[0] = $ts;
$currentPeriod[2] = $currents;
$isOpen = true;
} elseif ($isOpen && count($currents) <= $this->minCover) {
$currentPeriod[1] = $ts;
$overs[] = $currentPeriod;
$currentPeriod = [];
$isOpen = false;
} elseif ($isOpen) {
$currentPeriod[2] = \array_merge($currentPeriod[2], $currents);
}
}
// process metadata
foreach ($overs as list($start, $end, $metadata)) {
$this->intersections[] = [
(new \DateTimeImmutable('@'.$start))
->setTimezone($this->tz),
$end === PHP_INT_MAX ? null : (new \DateTimeImmutable('@'.$end))
->setTimezone($this->tz),
\array_values(
\array_intersect_key(
$this->metadatas,
\array_flip(\array_unique($metadata))
)
)
];
}
$this->computed = true;
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) {
throw new \LogicException(sprintf("You cannot call the method %s before ".
"'process'", __METHOD));
}
return count($this->intersections) > 0;
}
public function getIntersections(): array
{
if (!$this->computed) {
throw new \LogicException(sprintf("You cannot call the method %s before ".
"'process'", __METHOD));
}
return $this->intersections;
}
}

View File

@ -86,8 +86,19 @@ class AccompanyingCourseController extends Controller
*/
public function indexAction(AccompanyingPeriod $accompanyingCourse): Response
{
// compute some warnings
// get persons without household
$withoutHousehold = [];
foreach ($accompanyingCourse->getParticipations() as
$p) {
if (FALSE === $p->getPerson()->isSharingHousehold()) {
$withoutHousehold[] = $p->getPerson();
}
}
return $this->render('@ChillPerson/AccompanyingCourse/index.html.twig', [
'accompanyingCourse' => $accompanyingCourse
'accompanyingCourse' => $accompanyingCourse,
'withoutHousehold' => $withoutHousehold
]);
}

View File

@ -3,10 +3,13 @@
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Entity\Address;
use Chill\PersonBundle\Form\HouseholdType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Translation\TranslatorInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\Position;
@ -16,6 +19,16 @@ use Chill\PersonBundle\Entity\Household\Position;
*/
class HouseholdController extends AbstractController
{
private TranslatorInterface $translator;
/**
* @param TranslatorInterface $translator
*/
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
/**
* @Route(
* "/{household_id}/summary",
@ -66,10 +79,17 @@ class HouseholdController extends AbstractController
// some queries
$household->getMembers()->initialize();
if ($request->query->has('edit')) {
$form = $this->createMetadataForm($household);
} else {
$form = null;
}
return $this->render('@ChillPerson/Household/members.html.twig',
[
'household' => $household,
'positions' => $positions
'positions' => $positions,
'form' => NULL !== $form ? $form->createView(): $form
]
);
}
@ -143,4 +163,53 @@ class HouseholdController extends AbstractController
]
);
}
/**
* @Route(
* "/{household_id}/members/metadata/edit",
* name="chill_person_household_members_metadata_edit",
* methods={"GET", "POST"}
* )
* @ParamConverter("household", options={"id" = "household_id"})
*/
public function editHouseholdMetadata(Request $request, Household $household)
{
// TODO ACL
$form = $this->createMetadataForm($household);
$form->handleRequest($request);
if ($form->isSubmitted() and $form->isValid()) {
$this->getDoctrine()->getManager()->flush();
$this->addFlash('success', $this->translator->trans('household.data_saved'));
return $this->redirectToRoute('chill_person_household_members', [
'household_id' => $household->getId()
]);
}
return $this->render('@ChillPerson/Household/edit_member_metadata.html.twig', [
'household' => $household,
'form' => $form->createView()
]);
}
private function createMetadataForm(Household $household): FormInterface
{
$form = $this->createForm(
HouseholdType::class,
$household,
[
'action' => $this->generateUrl(
'chill_person_household_members_metadata_edit',
[
'household_id' => $household->getId()
]
)
]
);
return $form;
}
}

View File

@ -50,8 +50,12 @@ class HouseholdMemberController extends ApiController
// TODO ACL
//
// TODO validation
//
$errors = $editor->validate();
if (count($errors) > 0) {
return $this->json($errors, 422);
}
$em = $this->getDoctrine()->getManager();
// if new household, persist it
@ -155,7 +159,9 @@ class HouseholdMemberController extends ApiController
{
// TODO ACL
$form = $this->createForm(HouseholdMemberType::class, $member);
$form = $this->createForm(HouseholdMemberType::class, $member, [
'validation_groups' => [ 'household_memberships' ]
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {

View File

@ -41,6 +41,8 @@ use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Repository\PersonNotDuplicateRepository;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
final class PersonController extends AbstractController
{
@ -416,4 +418,29 @@ final class PersonController extends AbstractController
return $person;
}
/**
*
* @Route(
* "/{_locale}/person/household/{person_id}/history",
* name="chill_person_household_person_history",
* methods={"GET", "POST"}
* )
* @ParamConverter("person", options={"id" = "person_id"})
*/
public function householdHistoryByPerson(Request $request, Person $person): Response
{
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person,
"You are not allowed to see this person.");
$event = new PrivacyEvent($person);
$this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event);
return $this->render(
'@ChillPerson/Person/household_history.html.twig',
[
'person' => $person
]
);
}
}

View File

@ -11,6 +11,8 @@ use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Entity\Address;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
/**
* @ORM\Entity
@ -20,6 +22,7 @@ use Chill\PersonBundle\Entity\Household\HouseholdMember;
* @Serializer\DiscriminatorMap(typeProperty="type", mapping={
* "household"=Household::class
* })
* @MaxHolder(groups={"household_memberships"})
*/
class Household
{
@ -27,7 +30,7 @@ class Household
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read"})
*/
private ?int $id = null;
@ -52,10 +55,26 @@ class Household
*/
private Collection $members;
/**
* @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_members_")
*/
private CommentEmbeddable $commentMembers;
/**
* @ORM\Column(type="boolean", name="waiting_for_birth", options={"default": false})
*/
private bool $waitingForBirth = false;
/**
* @ORM\Column(type="date_immutable", name="waiting_for_birth_date", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $waitingForBirthDate = null;
public function __construct()
{
$this->addresses = new ArrayCollection();
$this->members = new ArrayCollection();
$this->commentMembers = new CommentEmbeddable();
}
public function getId(): ?int
@ -129,6 +148,53 @@ class Household
return $this->members;
}
public function getMembersOnRange(\DateTimeImmutable $from, ?\DateTimeImmutable $to): Collection
{
$criteria = new Criteria();
$expr = Criteria::expr();
$criteria->where(
$expr->gte('startDate', $from)
);
if (NULL !== $to) {
$criteria->andWhere(
$expr->orX(
$expr->lte('endDate', $to),
$expr->eq('endDate', NULL)
),
);
}
return $this->getMembers()
->matching($criteria)
;
}
public function getMembersDuringMembership(HouseholdMember $membership)
{
return $this->getMembersOnRange(
$membership->getStartDate(),
$membership->getEndDate()
)->filter(
function(HouseholdMember $m) use ($membership) {
return $m !== $membership;
}
);
}
public function getMembersHolder(): Collection
{
$criteria = new Criteria();
$expr = Criteria::expr();
$criteria->where(
$expr->eq('holder', true)
);
return $this->getMembers()->matching($criteria);
}
public function getCurrentMembers(?\DateTimeImmutable $now = null): Collection
{
$criteria = new Criteria();
@ -148,6 +214,22 @@ class Household
return $this->getMembers()->matching($criteria);
}
/**
* get current members ids
*
* Used in serialization
*
* @Serializer\Groups({"read"})
* @Serializer\SerializedName("current_members_id")
*
*/
public function getCurrentMembersIds(?\DateTimeImmutable $now = null): Collection
{
return $this->getCurrentMembers($now)->map(
fn (HouseholdMember $m) => $m->getId()
);
}
/**
* Get the persons currently associated to the household.
*
@ -236,7 +318,42 @@ class Household
}
}
dump($cond);
}
public function getCommentMembers(): CommentEmbeddable
{
return $this->commentMembers;
}
public function setCommentMembers(CommentEmbeddable $commentMembers): self
{
$this->commentMembers = $commentMembers;
return $this;
}
public function getWaitingForBirth(): bool
{
return $this->waitingForBirth;
}
public function setWaitingForBirth(bool $waitingForBirth): self
{
$this->waitingForBirth = $waitingForBirth;
return $this;
}
public function getWaitingForBirthDate(): ?\DateTimeImmutable
{
return $this->waitingForBirthDate;
}
public function setWaitingForBirthDate(?\DateTimeImmutable $waitingForBirthDate): self
{
$this->waitingForBirthDate = $waitingForBirthDate;
return $this;
}
}

View File

@ -7,6 +7,7 @@ use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\Position;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
@ -28,18 +29,25 @@ class HouseholdMember
/**
* @ORM\ManyToOne(targetEntity=Position::class)
* @Serializer\Groups({"read"})
* @Assert\NotNull(groups={"household_memberships"})
*/
private ?Position $position = null;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
* @Serializer\Groups({"read"})
* @Assert\NotNull(groups={"household_memberships"})
*/
private ?\DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="date_immutable", nullable= true, options={"default": null})
* @Serializer\Groups({"read"})
* @Assert\GreaterThan(
* propertyPath="startDate",
* message="household_membership.The end date must be after start date",
* groups={"household_memberships"}
* )
*/
private ?\DateTimeImmutable $endDate = null;
@ -67,6 +75,8 @@ class HouseholdMember
* targetEntity="\Chill\PersonBundle\Entity\Person"
* )
* @Serializer\Groups({"read"})
* @Assert\Valid(groups={"household_memberships"})
* @Assert\NotNull(groups={"household_memberships"})
*/
private ?Person $person = null;
@ -76,6 +86,8 @@ class HouseholdMember
* @ORM\ManyToOne(
* targetEntity="\Chill\PersonBundle\Entity\Household\Household"
* )
* @Assert\Valid(groups={"household_memberships"})
* @Assert\NotNull(groups={"household_memberships"})
*/
private ?Household $household = null;
@ -194,4 +206,13 @@ class HouseholdMember
{
return $this->holder;
}
public function isCurrent(\DateTimeImmutable $at = null): bool
{
$at = NULL === $at ? new \DateTimeImmutable('now'): $at;
return $this->getStartDate() < $at && (
NULL === $this->getEndDate() || $at < $this->getEndDate()
);
}
}

View File

@ -1219,6 +1219,50 @@ class Person implements HasCenterInterface
return $this->householdParticipations;
}
/**
* Get participation where the person does share the household.
*
* Order by startDate, desc
*/
public function getHouseholdParticipationsShareHousehold(): Collection
{
$criteria = new Criteria();
$expr = Criteria::expr();
$criteria
->where(
$expr->eq('shareHousehold', true)
)
->orderBy(['startDate' => Criteria::DESC])
;
return $this->getHouseholdParticipations()
->matching($criteria)
;
}
/**
* Get participation where the person does not share the household.
*
* Order by startDate, desc
*/
public function getHouseholdParticipationsNotShareHousehold(): Collection
{
$criteria = new Criteria();
$expr = Criteria::expr();
$criteria
->where(
$expr->eq('shareHousehold', false)
)
->orderBy(['startDate' => Criteria::DESC])
;
return $this->getHouseholdParticipations()
->matching($criteria)
;
}
public function getCurrentHousehold(?\DateTimeImmutable $at = null): ?Household
{
$criteria = new Criteria();

View File

@ -35,7 +35,8 @@ class HouseholdMemberType extends AbstractType
}
$builder
->add('comment', ChillTextareaType::class, [
'label' => 'household.Comment'
'label' => 'household.Comment',
'required' => false
])
;
}

View File

@ -0,0 +1,40 @@
<?php
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\PersonBundle\Entity\Household\Household;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class HouseholdType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('commentMembers', CommentType::class, [
'label' => 'household.comment_membership',
'required' => false
])
->add('waitingForBirth', CheckboxType::class, [
'required' => false,
'label' => 'household.expecting_birth'
])
->add('waitingForBirthDate', ChillDateType::class, [
'required' => false,
'label' => 'household.date_expecting_birth',
'input' => 'datetime_immutable'
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Household::class,
]);
}
}

View File

@ -3,6 +3,7 @@
namespace Chill\PersonBundle\Household;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Doctrine\Common\Collections\Criteria;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\Position;
@ -19,9 +20,11 @@ class MembersEditor
private array $persistables = [];
private array $membershipsAffected = [];
public const VALIDATION_GROUP = 'household_memberships';
public function __construct(ValidatorInterface $validator, ?Household $household)
{
$this->validation = $validator;
$this->validator = $validator;
$this->household = $household;
}
@ -41,12 +44,12 @@ class MembersEditor
$this->household->addMember($membership);
if ($position->getShareHousehold()) {
foreach ($person->getHouseholdParticipations() as $participation) {
if (FALSE === $participation->getShareHousehold()) {
foreach ($person->getHouseholdParticipationsShareHousehold() as $participation) {
if ($participation === $membership) {
continue;
}
if ($participation === $membership) {
if ($participation->getStartDate() > $membership->getStartDate()) {
continue;
}
@ -92,7 +95,18 @@ class MembersEditor
public function validate(): ConstraintViolationListInterface
{
if ($this->hasHousehold()) {
$list = $this->validator
->validate($this->getHousehold(), null, [ self::VALIDATION_GROUP ]);
} else {
$list = new ConstraintViolationList();
}
foreach ($this->membershipsAffected as $m) {
$list->addAll($this->validator->validate($m, null, [ self::VALIDATION_GROUP ]));
}
return $list;
}
public function getPersistable(): array
@ -100,6 +114,7 @@ class MembersEditor
return $this->persistables;
}
public function getHousehold(): ?Household
{
return $this->household;

View File

@ -64,6 +64,16 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
'order' => 50
]);
$menu->addChild($this->translator->trans('household.person history'), [
'route' => 'chill_person_household_person_history',
'routeParameters' => [
'person_id' => $parameters['person']->getId()
]
])
->setExtras([
'order' => 99999
]);
$menu->addChild($this->translator->trans('Person duplicate'), [
'route' => 'chill_person_duplicate_view',
'routeParameters' => [
@ -71,7 +81,7 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
]
])
->setExtras([
'order' => 51
'order' => 99999
]);
if ($this->showAccompanyingPeriod === 'visible') {

View File

@ -0,0 +1,25 @@
import { ShowHide } from 'ShowHide/show_hide.js';
let
k = document.getElementById('waitingForBirthContainer'),
waitingForBirthDate = document.getElementById('waitingForBirthDateContainer')
;
console.log(k );
new ShowHide({
'container': [waitingForBirthDate],
'froms': [k ],
'event_name': 'input',
'debug': true,
'test': function(froms, event) {
for (let f of froms.values()) {
console.log(f);
for (let input of f.querySelectorAll('input').values()) {
return input.checked;
}
}
return false;
}
});

View File

@ -16,32 +16,13 @@ const householdMove = (payload) => {
if (response.ok) {
return response.json();
}
throw Error('Error with testing move');
});
};
const householdMoveTest = (payload) => {
const url = `/api/1.0/person/household/members/move/test.json`;
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
.then(response => {
if (response.status === 422) {
return response.json();
}
if (response.ok) {
// return an empty array if ok
return new Promise((resolve, reject) => resolve({ violations: [] }) );
}
throw Error('Error with testing move');
});
};
export {
householdMove,
householdMoveTest
};

View File

@ -30,7 +30,7 @@
<img src="~ChillMainAssets/img/draggable.svg" class="drag-icon" />
<person :person="conc.person"></person>
</div>
<div>
<div v-if="conc.person.birthdate !== null">
{{ $t('person.born', {'gender': conc.person.gender} ) }}
{{ $d(conc.person.birthdate.datetime, 'short') }}
</div>

View File

@ -12,6 +12,9 @@
<li v-for="(msg, index) in warnings">
{{ $t(msg.m, msg.a) }}
</li>
<li v-for="msg in errors">
{{ msg }}
</li>
</ul>
<ul class="record_actions sticky-form-buttons">
@ -34,8 +37,9 @@ export default {
computed: {
...mapState({
warnings: (state) => state.warnings,
hasNoWarnings: (state) => state.warnings.length === 0,
hasWarnings: (state) => state.warnings.length > 0,
errors: (state) => state.errors,
hasNoWarnings: (state) => state.warnings.length === 0 && state.errors.length === 0,
hasWarnings: (state) => state.warnings.length > 0 || state.errors.length > 0,
}),
},
methods: {

View File

@ -8,7 +8,7 @@
{{ $t('household_members_editor.holder') }}
</span>
</div>
<div>{{ $t('person.born', {'gender': conc.person.gender} ) }}</div>
<div v-if="conc.person.birthdate !== null">{{ $t('person.born', {'gender': conc.person.gender} ) }}</div>
</div>
<div class="item-col box-where">
<ul class="list-content fa-ul">

View File

@ -1,6 +1,6 @@
<template>
<span class="chill-entity chill-entity__person">
<span class="chill-entity__person__text">
<span class="chill-entity__person__text chill_denomination">
{{ person.text }}
</span>
</span>

View File

@ -18,6 +18,45 @@
</div>
{% endif %}
{% if withoutHousehold|length > 0 %}
<div class="alert alert-danger alert-with-actions">
<div class="message">
{{ 'Some peoples does not belong to any household currently. Add them to an household soon'|trans }}
</div>
<ul class="record_actions">
<li>
<button class="btn btn-primary" data-toggle="collapse" href="#withoutHouseholdList">
{{ 'Add to household now'|trans }}
</button>
</li>
</ul>
</div>
<div id="withoutHouseholdList" class="collapse">
<form method="GET" action="{{ chill_path_add_return_path('chill_person_household_members_editor') }}">
<h3>{{ 'household.Select people to move'|trans }}</h3>
<ul>
{% for p in withoutHousehold %}
<li>
<input type="checkbox" name="persons[]" value="{{ p.id }}" />
{{ p|chill_entity_render_box }}
</li>
{% endfor %}
</ul>
<ul class="record_actions">
<li>
<button type="submit" class="sc-button bt-edit">
{{ 'household.Household editor'|trans }}
</button>
</li>
</ul>
</form>
</div>
{% endif %}
<h2>{{ 'Associated peoples'|trans }}</h2>
<div class="flex-table">
{% for p in accompanyingCourse.participations %}

View File

@ -0,0 +1,42 @@
{% extends '@ChillPerson/Household/layout.html.twig' %}
{% block title 'household.Edit member metadata'|trans %}
{% block content %}
<h1>{{ block('title') }}</h1>
{{ form_start(form) }}
{{ form_row(form.commentMembers) }}
<div id="waitingForBirthContainer">
{{ form_row(form.waitingForBirth) }}
</div>
<div id="waitingForBirthDateContainer">
{{ form_row(form.waitingForBirthDate) }}
</div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
href="{{ chill_path_add_return_path('chill_person_household_members', { 'household_id': household.id }) }}"
class="sc-button bt-cancel"
/>
{{ 'Cancel'|trans }}
</a>
</li>
<li>
<button type="submit" class="sc-button bt-save">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('household_edit_metadata') }}
{% endblock %}

View File

@ -5,6 +5,60 @@
{% block content %}
<h1>{{ block('title') }}</h1>
{% if form is not null %}
{{ form_start(form) }}
{{ form_row(form.commentMembers) }}
<div id="waitingForBirthContainer">
{{ form_row(form.waitingForBirth) }}
</div>
<div id="waitingForBirthDateContainer">
{{ form_row(form.waitingForBirthDate) }}
</div>
<ul class="record_actions">
<li>
<button type="submit" class="sc-button bt-save">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
{% else %}
{% if not household.commentMembers.isEmpty() %}
{{ household.commentMembers|chill_entity_render_box }}
{% endif %}
{% if household.waitingForBirth %}
{% if household.waitingForBirthDate is not null %}
{{ 'household.Expecting for birth on date'|trans({ 'date': household.waitingForBirthDate|format_date('long') }) }}
{% else %}
{{ 'household.Expecting for birth'|trans }}
{% endif %}
{% else %}
<p class="chill-no-data-statement">
{{ 'household.Any expecting birth'|trans }}
</p>
{% endif %}
<ul class="record_actions">
<li>
<a
href="{{ chill_path_add_return_path('chill_person_household_members', { 'household_id': household.id, 'edit': 1 }) }}"
class="sc-button bt-edit"
>
{{ 'household.Comment and expecting birth'|trans }}
</a>
</li>
</ul>
{% endif %}
{% for p in positions %}
<h3>{{ p.label|localize_translatable_string }}</h3>
@ -149,3 +203,7 @@
</ul>
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('household_edit_metadata') }}
{% endblock %}

View File

@ -0,0 +1,201 @@
{% extends "@ChillPerson/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_view' %}
{% block title 'household.Household history for %name%'|trans({'name': person|chill_entity_render_string}) %}
{% block personcontent %}
<h1>{{ block('title') }}</h1>
<h2>{{ 'household.Household shared'|trans }}</h2>
{% set memberships = person.getHouseholdParticipationsShareHousehold() %}
{% if memberships|length == 0 %}
<p class="chill-no-data-statement">{{ 'household.Never in any household'|trans }}</p>
<ul class="record_actions">
<li>
<a class="sc-button"
href="{{
chill_path_add_return_path(
'chill_person_household_members_editor',
{ 'persons': [ person.id ]}) }}"
>
<i class="fa fa-sign-out"></i>
{{ 'household.Join'|trans }}
</a>
</li>
</ul>
{% else %}
<div class="household">
<div class="household__address">
{% if not person.isSharingHousehold() %}
<div class="row">
<div class="household__address--date"></div>
<div class="household__address--content">
<div class="cell">
<a class="sc-button"
href="{{
chill_path_add_return_path(
'chill_person_household_members_editor',
{ 'persons': [ person.id ]}) }}"
>
<i class="fa fa-sign-out"></i>
{{ 'household.Join'|trans }}
</a>
</div>
</div>
</div>
{% endif %}
{% for p in memberships %}
<div class="row">
<div class="household__address--date">
<div class="cell">
<div class="pill">
{{ p.startDate|format_date('long') }}
</div>
</div>
</div>
<div class="household__address--content">
<div class="cell">
<i class="dot"></i>
<div>
<div>
<p>
<i class="fa fa-home"></i>
<a
href="{{ chill_path_add_return_path(
'chill_person_household_summary',
{ 'household_id': p.household.id }
) }}"
>
{{ 'household.Household number'|trans({'household_num': p.household.id }) }}
</a>
</p>
<p>{{ p.position.label|localize_translatable_string }} {% if p.holder %}<span class="badge badge-primary">{{ 'household.holder'|trans }}</span>{% endif %}
</div>
<div>
{% set simultaneous = p.household.getMembersDuringMembership(p) %}
{% if simultaneous|length == 0 %}
<p class="chill-no-data-statement">
{{ 'household.Any simultaneous members'|trans }}
</p>
{% else %}
{{ 'household.Members at same time'|trans }}:
{% for p in simultaneous -%}
{{- p.person|chill_entity_render_box({'addLink': true }) -}}
{%- if p.holder %} <span class="badge badge-primary">{{'household.holder'|trans }}</span> {% endif %}
{%- if not loop.last %}, {% endif -%}
{%- endfor -%}
{% endif %}
<ul class="record_actions">
<li>
<a
href="{{ chill_path_add_return_path('chill_person_household_member_edit', { id: p.id }) }}"
class="sc-button bt-edit"
></a>
</li>
{% if p.isCurrent() %}
<li>
<a class="sc-button"
href="{{ chill_path_add_return_path(
'chill_person_household_members_editor',
{ 'persons': [ person.id ], 'allow_leave_without_household': true }) }}"
>
<i class="fa fa-sign-out"></i>
{{ 'household.Leave'|trans }}
</a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<h2>{{ 'household.Household not shared'|trans }}</h2>
{% set memberships = person.getHouseholdParticipationsNotShareHousehold() %}
{% if memberships|length == 0 %}
<p class="chill-no-data-statement">{{ 'household.Never in any household'|trans }}</p>
{% else %}
<table>
<thead>
<tr>
<th>{{ 'household.from'|trans }}</th>
<th>{{ 'household.to'|trans }}</th>
<th>{{ 'household.Household'|trans }}</th>
</tr>
</thead>
<tbody>
{% for p in memberships %}
<tr>
<td>{{ p.startDate|format_date('long') }}</td>
<td>
{% if p.endDate is not empty %}
{{ p.endDate|format_date('long') }}
{% else %}
{{ 'household.Membership currently running'|trans }}
{% endif %}
</td>
<td>
<div>
<p>
<i class="fa fa-home"></i>
<a
href="{{ chill_path_add_return_path(
'chill_person_household_summary',
{ 'household_id': p.household.id }
) }}"
>
{{ 'household.Household number'|trans({'household_num': p.household.id }) }}
</a>
</p>
<p>{{ p.position.label|localize_translatable_string }} {% if p.holder %}<span class="badge badge-primary">{{ 'household.holder'|trans }}</span>{% endif %}
</div>
<div>
{% set simultaneous = p.household.getMembersDuringMembership(p) %}
{% if simultaneous|length == 0 %}
<p class="chill-no-data-statement">
{{ 'household.Any simultaneous members'|trans }}
</p>
{% else %}
{{ 'household.Members at same time'|trans }}:
{% for p in simultaneous -%}
{{- p.person|chill_entity_render_box({'addLink': true }) -}}
{%- if p.holder %} <span class="badge badge-primary">{{'household.holder'|trans }}</span> {% endif %}
{%- if not loop.last %}, {% endif -%}
{%- endfor -%}
{% endif %}
</div>
</td>
<td>
<ul class="record_actions">
<li>
<a
href="{{ chill_path_add_return_path('chill_person_household_member_edit', { id: p.id }) }}"
class="sc-button bt-edit"
></a>
</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,100 @@
<?php
namespace Chill\PersonBundle\Tests\Validator\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\Position;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequentialValidator;
use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class HouseholdMembershipSequentialValidatorTest extends ConstraintValidatorTestCase
{
public function testEmptyPerson()
{
$constraint = $this->getConstraint();
$person = new Person();
$this->validator->validate($person, $constraint);
$this->assertNoViolation();
}
public function testMembershipCovering()
{
$constraint = $this->getConstraint();
$person = new Person();
$household = new Household();
$position = (new Position())
->setShareHousehold(true)
;
$membership = (new HouseholdMember())
->setPosition($position)
->setStartDate(new \DateTimeImmutable('2010-01-01'))
->setPerson($person)
;
$membership = (new HouseholdMember())
->setPosition($position)
->setStartDate(new \DateTimeImmutable('2011-01-01'))
->setPerson($person)
;
$this->validator->validate($person, $constraint);
$this->buildViolation('msg')
->setParameters([
'%person_name%' => 'name',
'%from%' => '01-01-2011',
'%nbHousehold%' => 2
])
->assertRaised()
;
}
public function testMembershipCoveringNoShareHousehold()
{
$constraint = $this->getConstraint();
$person = new Person();
$household = new Household();
$position = (new Position())
->setShareHousehold(false)
;
$membership = (new HouseholdMember())
->setPosition($position)
->setStartDate(new \DateTimeImmutable('2010-01-01'))
->setPerson($person)
;
$membership = (new HouseholdMember())
->setPosition($position)
->setStartDate(new \DateTimeImmutable('2011-01-01'))
->setPerson($person)
;
$this->validator->validate($person, $constraint);
$this->assertNoViolation();
}
protected function getConstraint()
{
return new HouseholdMembershipSequential([
'message' => 'msg'
]);
}
protected function createValidator()
{
$render = $this->createMock(PersonRender::class);
$render->method('renderString')
->willReturn('name')
;
return new HouseholdMembershipSequentialValidator($render);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Chill\PersonBundle\Tests\Validator\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\Position;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Validator\Constraints\Household\MaxHolderValidator;
use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class MaxHolderValidatorTest extends ConstraintValidatorTestCase
{
/**
* @dataProvider provideInvalidHousehold
*/
public function testHouseholdInvalid(Household $household, $parameters)
{
$constraint = $this->getConstraint();
$this->validator->validate($household, $constraint);
$this->buildViolation('msg')
->setParameters($parameters)
->assertRaised()
;
}
protected function getConstraint()
{
return new MaxHolder([
'message' => 'msg',
'messageInfinity' => 'msgInfinity'
]);
}
public function provideInvalidHousehold()
{
$household = new Household();
$position = (new Position())
->setAllowHolder(true);
$household
->addMember(
(new HouseholdMember())
->setHolder(true)
->setStartDate(new \DateTimeImmutable('2010-01-01'))
->setEndDate(new \DateTimeImmutable('2010-12-01'))
)
->addMember(
(new HouseholdMember())
->setHolder(true)
->setStartDate(new \DateTimeImmutable('2010-06-01'))
->setEndDate(new \DateTimeImmutable('2010-07-01'))
)
->addMember(
(new HouseholdMember())
->setHolder(true)
->setStartDate(new \DateTimeImmutable('2010-01-01'))
->setEndDate(new \DateTimeImmutable('2010-12-01'))
)
;
yield [
$household,
[
'{{ start }}' => '01-06-2010',
'{{ end }}' => '01-07-2010'
]
];
}
protected function createValidator()
{
return new MaxHolderValidator();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Chill\PersonBundle\Validator\Constraints\Household;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class HouseholdMembershipSequential extends Constraint
{
public $message = 'household_membership.Person with membership covering';
public function getTargets()
{
return [ self::CLASS_CONSTRAINT ];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Chill\PersonBundle\Validator\Constraints\Household;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Util\DateRangeCovering;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
/**
* Validate that a person does not belong to two household at
* the same time
*/
class HouseholdMembershipSequentialValidator extends ConstraintValidator
{
private PersonRender $render;
public function __construct(PersonRender $render)
{
$this->render = $render;
}
public function validate($person, Constraint $constraint)
{
if (!$person instanceof Person) {
throw new UnexpectedTypeException($constraint, Person::class);
}
$participations = $person->getHouseholdParticipationsShareHousehold();
if ($participations->count() === 0) {
return;
}
$covers = new DateRangeCovering(1, $participations->first()
->getStartDate()->getTimezone());
foreach ($participations as $k => $p) {
$covers->add($p->getStartDate(), $p->getEndDate(), $k);
}
$covers->compute();
if ($covers->hasIntersections()) {
foreach ($covers->getIntersections() as list($start, $end, $metadata)) {
$participation = $participations[$metadata[0]];
$nbHousehold = count($metadata);
$this->context
->buildViolation($constraint->message)
->setParameters([
'%person_name%' => $this->render->renderString(
$participation->getPerson(), []
),
// TODO when date is correctly i18n, fix this
'%from%' => $start->format('d-m-Y'),
'%nbHousehold%' => $nbHousehold,
])
->addViolation()
;
}
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Chill\PersonBundle\Validator\Constraints\Household;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class MaxHolder extends Constraint
{
public $message = 'household.max_holder_overflowed';
public $messageInfinity = 'household.max_holder_overflowed_infinity';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@ -2,7 +2,46 @@
namespace Chill\PersonBundle\Validator\Constraints\Household;
class MaxHolderValidator
use Chill\MainBundle\Util\DateRangeCovering;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class MaxHolderValidator extends ConstraintValidator
{
private const MAX_HOLDERS = 2;
public function validate($household, Constraint $constraint)
{
$holders = $household->getMembersHolder();
if ($holders->count() <= self::MAX_HOLDERS) {
return;
}
$covers = new DateRangeCovering(self::MAX_HOLDERS,
$holders[0]->getStartDate()->getTimezone());
foreach ($holders as $key => $member) {
$covers->add($member->getStartDate(), $member->getEndDate(), $key);
}
$covers->compute();
if ($covers->hasIntersections()) {
foreach ($covers->getIntersections() as list($start, $end, $ids)) {
$msg = $end === null ? $constraint->messageInfinity :
$constraint->message;
$this->context->buildViolation($msg)
->setParameters([
'{{ start }}' => $start->format('d-m-Y'), // TODO fix when MessageParameter works with timezone
'{{ end }}' => $end === null ? null : $end->format('d-m-Y')
])
->addViolation();
}
}
}
}

View File

@ -12,4 +12,5 @@ module.exports = function(encore, entries)
encore.addEntry('household_address', __dirname + '/Resources/public/vuejs/HouseholdAddress/index.js');
encore.addEntry('household_members_editor', __dirname + '/Resources/public/vuejs/HouseholdMembersEditor/index.js');
encore.addEntry('vue_accourse', __dirname + '/Resources/public/vuejs/AccompanyingCourse/index.js');
encore.addEntry('household_edit_metadata', __dirname + '/Resources/public/modules/household_edit_metadata/index.js');
};

View File

@ -57,6 +57,11 @@ services:
tags:
- { name: validator.constraint_validator, alias: birthdate_not_before }
Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequentialValidator:
autowire: true
tags:
- { name: validator.constraint_validator }
Chill\PersonBundle\Repository\:
autowire: true
autoconfigure: true

View File

@ -61,6 +61,9 @@ Chill\PersonBundle\Entity\Person:
- Callback:
callback: isAddressesValid
groups: [addresses_consistent]
- Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential:
groups: [ 'household_memberships' ]
Chill\PersonBundle\Entity\AccompanyingPeriod:
properties:

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add comments and expecting birth to household
*/
final class Version20210614191600 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comments and expecting birth to household';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_household ADD comment_members TEXT DEFAULT \'\' NOT NULL');
$this->addSql('ALTER TABLE chill_person_household ADD waiting_for_birth BOOLEAN DEFAULT \'false\' NOT NULL');
$this->addSql('ALTER TABLE chill_person_household ADD waiting_for_birth_date DATE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN chill_person_household.waiting_for_birth_date IS \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_household DROP comment_members');
$this->addSql('ALTER TABLE chill_person_household DROP waiting_for_birth');
$this->addSql('ALTER TABLE chill_person_household DROP waiting_for_birth_date');
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Set comment in household as Embedded Comment
*/
final class Version20210615074857 extends AbstractMigration
{
public function getDescription(): string
{
return 'replace comment in household as embedded comment';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_household RENAME comment_members TO comment_members_comment');
$this->addSql('ALTER TABLE chill_person_household ALTER COLUMN comment_members_comment DROP NOT NULL');
$this->addSql('ALTER TABLE chill_person_household ALTER COLUMN comment_members_comment SET DEFAULT NULL');
$this->addSql('ALTER TABLE chill_person_household ADD comment_members_userId INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_person_household ADD comment_members_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_person_household ADD CONSTRAINT fk_household_comment_embeddable_user FOREIGN KEY (comment_members_userId) REFERENCES users (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_household RENAME comment_members_comment TO comment_members');
$this->addSql('ALTER TABLE chill_person_household ALTER comment_members SET DEFAULT \'\'');
$this->addSql('ALTER TABLE chill_person_household ALTER comment_members SET NOT NULL');
$this->addSql('ALTER TABLE chill_person_household DROP comment_members_comment');
$this->addSql('ALTER TABLE chill_person_household DROP comment_members_userId');
$this->addSql('ALTER TABLE chill_person_household DROP comment_members_date');
}
}

View File

@ -7,7 +7,12 @@ Born the date: >-
household:
Household: Ménage
Household number: Ménage {household_num}
Household members: Membres du ménage
Household editor: Modifier l'appartenance
Members at same time: Membres simultanés
Any simultaneous members: Aucun membre simultanément
Select people to move: Choisir les usagers
Show future or past memberships: >-
{length, plural,
one {Montrer une ancienne appartenance}
@ -17,6 +22,7 @@ household:
Those members does not share address: Ces usagers ne partagent pas l'adresse du ménage.
Any persons into this position: Aucune personne n'appartient au ménage à cette position.
Leave: Quitter le ménage
Join: Rejoindre un ménage
Household file: Dossier ménage
Add a member: Ajouter un membre
Update membership: Modifier
@ -38,3 +44,21 @@ household:
many {et # autres personnes}
other {et # autres personnes}
}
Expecting for birth on date: Naissance attendue pour le {date}
Expecting for birth: Naissance attendue (date inconnue)
Any expecting birth: Aucune naissance proche n'a été renseignée.
Comment and expecting birth: Commentaire et naissance attendue
Edit member metadata: Données supplémentaires
comment_membership: Commentaire général sur les membres
expecting_birth: Naissance attendue ?
date_expecting_birth: Date de la naissance attendue
data_saved: Données enregistrées
Household history for %name%: Historique des ménages pour {name}
Household shared: Ménages domiciliés
Household not shared: Ménage non domiciliés
Never in any household: Membre d'aucun ménage
Membership currently running: En cours
from: Depuis
to: Jusqu'au
person history: Ménages

View File

@ -178,6 +178,8 @@ Edit & activate accompanying course: Modifier et valider
See accompanying periods: Voir les périodes d'accompagnement
See accompanying period: Voir cette période d'accompagnement
Referrer: Référent
Some peoples does not belong to any household currently. Add them to an household soon: Certaines personnes n'appartiennent à aucun ménage actuellement. Renseignez leur appartenance à un ménage dès que possible.
Add to household now: Ajouter à un ménage
# pickAPersonType
Pick a person: Choisir une personne

View File

@ -33,3 +33,11 @@ You should select an option: Une option doit être choisie.
# aggregator by age
The date should not be empty: La date ne doit pas être vide
# household
household:
max_holder_overflowed_infinity: Il ne peut pas y avoir plus de deux titulaires simultanément. Or, avec cette modification, ce nombre sera dépassé à partir du {{ start }}.
max_holder_overflowed: Il ne peut y avoir plus de deux titulaires simultanément. Or, avec cette modification, ce nombre sera dépassé entre le {{ start }} et le {{ end }}.
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%.