feat: offer the questionnaire for placements ended within a rolling window

This commit is contained in:
fromme committed 2026-11-10 09:23:23 +01:00
1 parent 9d0cdd7f52
commit 3c34cfa76a
8 files changed
+65 -55

No files matched your search

+5 -8
View File
@@ -121,11 +121,10 @@ parameters:
season_start: '11-01' season_start: '11-01'
season_end: '04-01' season_end: '04-01'
# The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is # The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
# offered for placements ending on or after this day (Y-m-d). Set it to the deployment day: # bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
# without a cutoff every past placement would show up as pending on the teamers' dashboards. # days. Without a window every past placement would show up as pending on the teamers' dashboards.
# null offers it to nobody. questionnaire_window_days: 30
questionnaire_go_live: ~
# How far ahead of the destination's first travel day a season assignment's checklist # How far ahead of the destination's first travel day a season assignment's checklist
# entries fall due. A relative date expression, applied to destination.dateFrom, and the # entries fall due. A relative date expression, applied to destination.dateFrom, and the
@@ -190,7 +189,7 @@ services:
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog. # Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
$questionnaireDir: '%kernel.project_dir%/config/questionnaires' $questionnaireDir: '%kernel.project_dir%/config/questionnaires'
$questionnaireGoLive: '%questionnaire_go_live%' $questionnaireWindowDays: '%questionnaire_window_days%'
$mailingBusTransport: '%mailing_bus_transport%' $mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%' $seasonStart: '%season_start%'
$seasonEnd: '%season_end%' $seasonEnd: '%season_end%'
@@ -516,5 +515,3 @@ when@test:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles # a job profile id ListControllerTest inserts itself - db_test has no job profiles
assignment_export.job_profile_ids: [ 900001 ] assignment_export.job_profile_ids: [ 900001 ]
mailing_bus_transport: 'sync' mailing_bus_transport: 'sync'
# the questionnaire tests need it offered; any day before the placements they create
questionnaire_go_live: '2026-01-01'
+7 -7
View File
@@ -57,17 +57,17 @@ class QuestionnaireCatalog
} }
/** /**
* The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is * The first end date the teamer questionnaire is still offered for: the services.yaml
* offered for placements ending on or after it, and to nobody while it is null. * parameter questionnaire_window_days, in days before today. Placements that ended earlier are neither
* pending nor open, so a deploy does not offer it for every past placement at once.
*/ */
public static function parseGoLive(?string $goLive): ?\DateTimeImmutable public static function windowStart(int $windowDays): \DateTimeImmutable
{ {
if (null === $goLive) { if ($windowDays < 0) {
return null; throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays));
} }
return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive) return new \DateTimeImmutable(sprintf('today -%d days', $windowDays));
?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive));
} }
/** /**
+5 -4
View File
@@ -72,12 +72,13 @@ class IndexController extends AbstractController
$dispositionDocuments = $this->processDocuments($upcomingDispositions); $dispositionDocuments = $this->processDocuments($upcomingDispositions);
// offered to nobody until the go-live date is configured, as in DispositionVoter $pendingQuestionnaires = $this
$questionnaireGoLive = QuestionnaireCatalog::parseGoLive($this->getParameter('questionnaire_go_live'));
$pendingQuestionnaires = null === $questionnaireGoLive ? [] : $this
->entityManager ->entityManager
->getRepository(Disposition::class) ->getRepository(Disposition::class)
->findPendingQuestionnaireByTeamer($teamer, $questionnaireGoLive) ->findPendingQuestionnaireByTeamer(
$teamer,
QuestionnaireCatalog::windowStart((int) $this->getParameter('questionnaire_window_days')),
)
; ;
// the list merges in season assignments; the document notices above stay on dispositions // the list merges in season assignments; the document notices above stay on dispositions
+7 -7
View File
@@ -80,8 +80,8 @@ class DispositionRepository extends ServiceEntityRepository
* The teamer's placements with a questionnaire still to be submitted - not started, or saved as * The teamer's placements with a questionnaire still to be submitted - not started, or saved as
* a draft: the teamer feedback, and after a bus escort the bus feedback too * a draft: the teamer feedback, and after a bus escort the bus feedback too
* (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of * (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called * DispositionVoter::QUESTIONNAIRE - over (today included), on or after $windowStart, neither
* off nor skip-formalities - so every row shown on the dashboard can be opened. * called off nor skip-formalities - so every row shown on the dashboard can be opened.
* *
* The responses are joined as arbitrary entities because the association is one-way; the * The responses are joined as arbitrary entities because the association is one-way; the
* questionnaire key has to be part of each JOIN condition, or one questionnaire's response * questionnaire key has to be part of each JOIN condition, or one questionnaire's response
@@ -90,7 +90,7 @@ class DispositionRepository extends ServiceEntityRepository
* Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus, * Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus,
* 'busStatus' => ?QuestionnaireResponseStatus]. * 'busStatus' => ?QuestionnaireResponseStatus].
*/ */
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $windowStart): Query
{ {
$qb = $this->createQueryBuilder('disposition'); $qb = $this->createQueryBuilder('disposition');
@@ -115,7 +115,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'), $qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'), $qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
$qb->expr()->gte('destination.dateTo', ':goLive'), $qb->expr()->gte('destination.dateTo', ':windowStart'),
$qb->expr()->lte('destination.dateTo', ':today'), $qb->expr()->lte('destination.dateTo', ':today'),
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->isNull('teamer_response.id'), $qb->expr()->isNull('teamer_response.id'),
@@ -138,7 +138,7 @@ class DispositionRepository extends ServiceEntityRepository
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false) ->setParameter('skipFormalities', false)
->setParameter('goLive', $goLive, Types::DATE_IMMUTABLE) ->setParameter('windowStart', $windowStart, Types::DATE_IMMUTABLE)
->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE) ->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE)
->setParameter('draft', QuestionnaireResponseStatus::DRAFT) ->setParameter('draft', QuestionnaireResponseStatus::DRAFT)
->getQuery() ->getQuery()
@@ -148,7 +148,7 @@ class DispositionRepository extends ServiceEntityRepository
/** /**
* @return list<array{disposition: Disposition, draft: bool}> * @return list<array{disposition: Disposition, draft: bool}>
*/ */
public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $goLive): array public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $windowStart): array
{ {
return array_map( return array_map(
static fn (array $row): array => [ static fn (array $row): array => [
@@ -156,7 +156,7 @@ class DispositionRepository extends ServiceEntityRepository
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus'] 'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|| QuestionnaireResponseStatus::DRAFT === $row['busStatus'], || QuestionnaireResponseStatus::DRAFT === $row['busStatus'],
], ],
$this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(), $this->getPendingQuestionnaireQuery($teamer, $windowStart)->getResult(),
); );
} }
+4 -7
View File
@@ -25,16 +25,13 @@ class DispositionVoter extends Voter
public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS'; public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS';
public const QUESTIONNAIRE = 'QUESTIONNAIRE'; public const QUESTIONNAIRE = 'QUESTIONNAIRE';
private readonly ?\DateTimeImmutable $questionnaireGoLive;
/** /**
* @param string|null $questionnaireGoLive Y-m-d, the services.yaml parameter questionnaire_go_live * @param int $questionnaireWindowDays the services.yaml parameter questionnaire_window_days
*/ */
public function __construct( public function __construct(
private readonly Security $security, private readonly Security $security,
?string $questionnaireGoLive, private readonly int $questionnaireWindowDays,
) { ) {
$this->questionnaireGoLive = QuestionnaireCatalog::parseGoLive($questionnaireGoLive);
} }
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
@@ -134,7 +131,7 @@ class DispositionVoter extends Voter
*/ */
private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool
{ {
if (null === $this->questionnaireGoLive || false === $this->assertTeamerAccess($token, $disposition)) { if (false === $this->assertTeamerAccess($token, $disposition)) {
return false; return false;
} }
@@ -146,7 +143,7 @@ class DispositionVoter extends Voter
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo(); $dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
return null !== $dateTo return null !== $dateTo
&& $dateTo >= $this->questionnaireGoLive && $dateTo >= QuestionnaireCatalog::windowStart($this->questionnaireWindowDays)
&& $dateTo <= new \DateTimeImmutable('today'); && $dateTo <= new \DateTimeImmutable('today');
} }
+12
View File
@@ -112,6 +112,18 @@ class QuestionnaireCatalogTest extends TestCase
$this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label); $this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label);
} }
public function testTheWindowStartsTheGivenDaysBeforeToday(): void
{
$this->assertEquals(new \DateTimeImmutable('today -2 days'), QuestionnaireCatalog::windowStart(2));
}
public function testANegativeWindowFailsLoudly(): void
{
$this->expectException(\InvalidArgumentException::class);
QuestionnaireCatalog::windowStart(-1);
}
public function testUnknownQuestionnaireIsRejected(): void public function testUnknownQuestionnaireIsRejected(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
@@ -25,8 +25,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{ {
use QuestionnaireFixturesTrait; use QuestionnaireFixturesTrait;
private const string GO_LIVE = '2026-01-01';
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private DispositionRepository $repository; private DispositionRepository $repository;
@@ -123,6 +121,13 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
} }
public function testAnAssignmentEndingOnTheWindowsFirstDayIsPending(): void
{
$this->escort(dateTo: 'today -30 days');
$this->assertCount(1, $this->pending());
}
public function testAnAssignmentEndingTodayIsPending(): void public function testAnAssignmentEndingTodayIsPending(): void
{ {
$this->escort(dateTo: 'today'); $this->escort(dateTo: 'today');
@@ -149,7 +154,7 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{ {
return [ return [
'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'ended before the window' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('today -31 days'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)], 'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)],
'assignment called off' => [static fn (Disposition $d) => $d->getAssignment()->setStatus(Assignment::STATUS_CALLED_OFF)], 'assignment called off' => [static fn (Disposition $d) => $d->getAssignment()->setStatus(Assignment::STATUS_CALLED_OFF)],
@@ -172,6 +177,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{ {
$this->entityManager->flush(); $this->entityManager->flush();
return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, new \DateTimeImmutable(self::GO_LIVE)); return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, QuestionnaireCatalog::windowStart(30));
} }
} }
+16 -18
View File
@@ -159,7 +159,7 @@ class DispositionVoterTest extends TestCase
$this->assertSame( $this->assertSame(
$expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED,
(new DispositionVoter($this->security, null))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), (new DispositionVoter($this->security, 30))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]),
); );
} }
@@ -217,15 +217,20 @@ class DispositionVoterTest extends TestCase
{ {
return [ return [
'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'ended before the window' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('today -31 days'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)], 'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)],
]; ];
} }
public function testTheQuestionnaireIsOfferedToNobodyWithoutAGoLiveDate(): void public function testTheQuestionnaireIsStillOfferedOnTheWindowsLastDay(): void
{ {
$this->assertQuestionnaire(false, $this->escortDisposition(), goLive: null); $this->assertQuestionnaire(true, $this->escortDisposition(dateTo: 'today -30 days'));
}
public function testTheWindowIsConfigurable(): void
{
$this->assertQuestionnaire(false, $this->escortDisposition(dateTo: 'today -8 days'), windowDays: 7);
} }
public function testAnotherTeamerMayNotFillInTheQuestionnaire(): void public function testAnotherTeamerMayNotFillInTheQuestionnaire(): void
@@ -247,18 +252,11 @@ class DispositionVoterTest extends TestCase
$this->assertSame( $this->assertSame(
VoterInterface::ACCESS_DENIED, VoterInterface::ACCESS_DENIED,
$this->questionnaireVote($disposition, (new User())->setTeamer($disposition->getTeamer()), '2026-01-01'), $this->questionnaireVote($disposition, (new User())->setTeamer($disposition->getTeamer()), 30),
); );
} }
public function testAMalformedGoLiveDateFailsLoudly(): void private function assertQuestionnaire(bool $expected, Disposition $disposition, int $windowDays = 30, ?User $user = null): void
{
$this->expectException(\InvalidArgumentException::class);
new DispositionVoter($this->security, '01.01.2026');
}
private function assertQuestionnaire(bool $expected, Disposition $disposition, ?string $goLive = '2026-01-01', ?User $user = null): void
{ {
$this->security $this->security
->method('isGranted') ->method('isGranted')
@@ -267,20 +265,20 @@ class DispositionVoterTest extends TestCase
$this->assertSame( $this->assertSame(
$expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED,
$this->questionnaireVote($disposition, $user ?? (new User())->setTeamer($disposition->getTeamer()), $goLive), $this->questionnaireVote($disposition, $user ?? (new User())->setTeamer($disposition->getTeamer()), $windowDays),
); );
} }
private function questionnaireVote(Disposition $disposition, User $user, ?string $goLive): int private function questionnaireVote(Disposition $disposition, User $user, int $windowDays): int
{ {
$token = $this->createStub(TokenInterface::class); $token = $this->createStub(TokenInterface::class);
$token->method('getUser')->willReturn($user); $token->method('getUser')->willReturn($user);
return (new DispositionVoter($this->security, $goLive))->vote($token, $disposition, [DispositionVoter::QUESTIONNAIRE]); return (new DispositionVoter($this->security, $windowDays))->vote($token, $disposition, [DispositionVoter::QUESTIONNAIRE]);
} }
/** /**
* A bus escort that ended yesterday, after the go-live date the tests use. * A bus escort that ended yesterday, well within the 30-day window the tests use.
*/ */
private function escortDisposition(string $dateTo = 'yesterday'): Disposition private function escortDisposition(string $dateTo = 'yesterday'): Disposition
{ {
@@ -296,7 +294,7 @@ class DispositionVoterTest extends TestCase
private function vote(string $attribute, Disposition $disposition): int private function vote(string $attribute, Disposition $disposition): int
{ {
return (new DispositionVoter($this->security, null))->vote( return (new DispositionVoter($this->security, 30))->vote(
$this->createStub(TokenInterface::class), $this->createStub(TokenInterface::class),
$disposition, $disposition,
[$attribute], [$attribute],