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_end: '04-01'
# The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is
# offered for placements ending on or after this day (Y-m-d). Set it to the deployment day:
# without a cutoff every past placement would show up as pending on the teamers' dashboards.
# null offers it to nobody.
questionnaire_go_live: ~
# The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
# bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
# days. Without a window every past placement would show up as pending on the teamers' dashboards.
questionnaire_window_days: 30
# 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
@@ -190,7 +189,7 @@ services:
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
$questionnaireGoLive: '%questionnaire_go_live%'
$questionnaireWindowDays: '%questionnaire_window_days%'
$mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%'
$seasonEnd: '%season_end%'
@@ -516,5 +515,3 @@ when@test:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
assignment_export.job_profile_ids: [ 900001 ]
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
* offered for placements ending on or after it, and to nobody while it is null.
* The first end date the teamer questionnaire is still offered for: the services.yaml
* 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) {
return null;
if ($windowDays < 0) {
throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays));
}
return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive)
?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive));
return new \DateTimeImmutable(sprintf('today -%d days', $windowDays));
}
/**
+5 -4
View File
@@ -72,12 +72,13 @@ class IndexController extends AbstractController
$dispositionDocuments = $this->processDocuments($upcomingDispositions);
// offered to nobody until the go-live date is configured, as in DispositionVoter
$questionnaireGoLive = QuestionnaireCatalog::parseGoLive($this->getParameter('questionnaire_go_live'));
$pendingQuestionnaires = null === $questionnaireGoLive ? [] : $this
$pendingQuestionnaires = $this
->entityManager
->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
+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
* 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
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
* off nor skip-formalities - so every row shown on the dashboard can be opened.
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after $windowStart, neither
* 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
* 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,
* 'busStatus' => ?QuestionnaireResponseStatus].
*/
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $windowStart): Query
{
$qb = $this->createQueryBuilder('disposition');
@@ -115,7 +115,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
$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()->orX(
$qb->expr()->isNull('teamer_response.id'),
@@ -138,7 +138,7 @@ class DispositionRepository extends ServiceEntityRepository
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false)
->setParameter('goLive', $goLive, Types::DATE_IMMUTABLE)
->setParameter('windowStart', $windowStart, Types::DATE_IMMUTABLE)
->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE)
->setParameter('draft', QuestionnaireResponseStatus::DRAFT)
->getQuery()
@@ -148,7 +148,7 @@ class DispositionRepository extends ServiceEntityRepository
/**
* @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(
static fn (array $row): array => [
@@ -156,7 +156,7 @@ class DispositionRepository extends ServiceEntityRepository
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|| 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 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(
private readonly Security $security,
?string $questionnaireGoLive,
private readonly int $questionnaireWindowDays,
) {
$this->questionnaireGoLive = QuestionnaireCatalog::parseGoLive($questionnaireGoLive);
}
protected function supports(string $attribute, mixed $subject): bool
@@ -134,7 +131,7 @@ class DispositionVoter extends Voter
*/
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;
}
@@ -146,7 +143,7 @@ class DispositionVoter extends Voter
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
return null !== $dateTo
&& $dateTo >= $this->questionnaireGoLive
&& $dateTo >= QuestionnaireCatalog::windowStart($this->questionnaireWindowDays)
&& $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);
}
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
{
$this->expectException(\InvalidArgumentException::class);
@@ -25,8 +25,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{
use QuestionnaireFixturesTrait;
private const string GO_LIVE = '2026-01-01';
private EntityManagerInterface $entityManager;
private DispositionRepository $repository;
@@ -123,6 +121,13 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
$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
{
$this->escort(dateTo: 'today');
@@ -149,7 +154,7 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{
return [
'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)],
'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)],
@@ -172,6 +177,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{
$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(
$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 [
'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)],
'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
@@ -247,18 +252,11 @@ class DispositionVoterTest extends TestCase
$this->assertSame(
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
{
$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
private function assertQuestionnaire(bool $expected, Disposition $disposition, int $windowDays = 30, ?User $user = null): void
{
$this->security
->method('isGranted')
@@ -267,20 +265,20 @@ class DispositionVoterTest extends TestCase
$this->assertSame(
$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->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
{
@@ -296,7 +294,7 @@ class DispositionVoterTest extends TestCase
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),
$disposition,
[$attribute],