feat: offer the questionnaire for placements ended within a rolling window
This commit is contained in:
1 parent
9d0cdd7f52
commit
3c34cfa76a
8 files changed
+65
-55
No files matched your search
@@ -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'
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user