fix: offer the questionnaire after every placement, the bus part to escorts
This commit is contained in:
1 parent
9fedc5b5d3
commit
2f4f9800dd
30 files changed
+311
-77
No files matched your search
@@ -4,6 +4,7 @@ namespace App\Config;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
@@ -26,12 +27,12 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class QuestionnaireCatalog
|
||||
{
|
||||
/**
|
||||
* The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE.
|
||||
* The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
|
||||
*/
|
||||
public const string TEAMER_ESCORT = 'teamer_escort';
|
||||
public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
|
||||
|
||||
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
|
||||
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
|
||||
private const array OPTION_FIELDS = ['key', 'label'];
|
||||
|
||||
@@ -53,7 +54,7 @@ class QuestionnaireCatalog
|
||||
|
||||
/**
|
||||
* The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is
|
||||
* offered for assignments ending on or after it, and to nobody while it is null.
|
||||
* offered for placements ending on or after it, and to nobody while it is null.
|
||||
*/
|
||||
public static function parseGoLive(?string $goLive): ?\DateTimeImmutable
|
||||
{
|
||||
@@ -129,6 +130,8 @@ class QuestionnaireCatalog
|
||||
$sectionKeys = [];
|
||||
/** @var array<string, Question> $questions every question parsed so far, for show_if */
|
||||
$questions = [];
|
||||
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
|
||||
$questionFlags = [];
|
||||
|
||||
foreach ($data['sections'] as $index => $sectionData) {
|
||||
$context = sprintf('%s: section #%d', $file, $index + 1);
|
||||
@@ -146,15 +149,38 @@ class QuestionnaireCatalog
|
||||
throw $this->error($context, 'questions must be a non-empty list');
|
||||
}
|
||||
|
||||
$onlyFor = null;
|
||||
|
||||
if (array_key_exists('only_for', $sectionData)) {
|
||||
$onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for'])
|
||||
?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for']));
|
||||
}
|
||||
|
||||
$sectionQuestions = [];
|
||||
|
||||
foreach ($sectionData['questions'] as $questionData) {
|
||||
$question = $this->buildQuestion($questionData, $questions, $context);
|
||||
|
||||
// A question that may be asked without the flag cannot depend on one that is only
|
||||
// asked with it: it would stay hidden for everyone without the flag.
|
||||
if (null !== $question->showIf
|
||||
&& null !== ($controllingFlag = $questionFlags[$question->showIf->question])
|
||||
&& $controllingFlag !== $onlyFor
|
||||
) {
|
||||
throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value));
|
||||
}
|
||||
|
||||
$questions[$question->key] = $question;
|
||||
$questionFlags[$question->key] = $onlyFor;
|
||||
$sectionQuestions[] = $question;
|
||||
}
|
||||
|
||||
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
|
||||
$sections[] = new Section(
|
||||
$key,
|
||||
$this->optionalString($sectionData['title'] ?? null, 'title', $context),
|
||||
$sectionQuestions,
|
||||
$onlyFor,
|
||||
);
|
||||
}
|
||||
|
||||
return new Definition(
|
||||
|
||||
@@ -33,7 +33,7 @@ class IndexController extends AbstractController
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filterDto),
|
||||
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
@@ -43,7 +43,7 @@ class IndexController extends AbstractController
|
||||
);
|
||||
|
||||
return $this->render('administrative/questionnaire/index.html.twig', [
|
||||
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT),
|
||||
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
|
||||
'pagination' => $pagination,
|
||||
'filterDto' => $filterDto,
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Questionnaire;
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\Assignment;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
use App\Form\QuestionnaireType;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -14,9 +15,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without
|
||||
* bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the
|
||||
* answers as they would be stored, "Entwurf speichern" just keeps them in the form.
|
||||
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after
|
||||
* the trip, placed or not. It shows the assignment's own variant, with or without the bus
|
||||
* sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers
|
||||
* as they would be stored, "Entwurf speichern" just keeps them in the form.
|
||||
*/
|
||||
class TestController extends AbstractController
|
||||
{
|
||||
@@ -34,7 +36,13 @@ class TestController extends AbstractController
|
||||
Assignment $assignment,
|
||||
Request $request,
|
||||
): Response {
|
||||
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT);
|
||||
$busEscort = $request->query->has('bus_escort')
|
||||
? $request->query->getBoolean('bus_escort')
|
||||
: $assignment->hasBusEscort();
|
||||
$definition = $this->catalog
|
||||
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : [])
|
||||
;
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
|
||||
$form->handleRequest($request);
|
||||
@@ -56,6 +64,7 @@ class TestController extends AbstractController
|
||||
'form' => $form,
|
||||
'submitted' => $submitted,
|
||||
'draft' => $draft,
|
||||
'busEscort' => $busEscort,
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class DetailController extends AbstractController
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Disposition $disposition,
|
||||
): Response {
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
|
||||
|
||||
if (true !== $response?->isSubmitted()) {
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
|
||||
|
||||
@@ -18,8 +18,9 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer's questionnaire after a bus escort, saved as a draft until it is submitted. A
|
||||
* submitted one is final and only shown read-only (DetailController).
|
||||
* The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
|
||||
* saved as a draft until it is submitted. A submitted one is final and only shown read-only
|
||||
* (DetailController).
|
||||
*/
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
@@ -39,13 +40,16 @@ class EditController extends AbstractController
|
||||
Disposition $disposition,
|
||||
Request $request,
|
||||
): Response {
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
|
||||
|
||||
if (true === $response?->isSubmitted()) {
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT);
|
||||
$definition = $this->catalog
|
||||
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->forFlags(DispositionContext::flags($disposition->getAssignment()))
|
||||
;
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
|
||||
'definition' => $definition,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Something a questionnaire's subject may or may not have, deciding whether a section marked
|
||||
* `only_for` it is part of the questionnaire at all. Unlike show_if, it is not answered by the
|
||||
* respondent but known beforehand - for a disposition, by DispositionContext::flags().
|
||||
*/
|
||||
enum QuestionnaireFlag: string
|
||||
{
|
||||
case BUS_ESCORT = 'bus_escort';
|
||||
}
|
||||
@@ -112,10 +112,10 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
$menu->addChild('Fragebögen', [
|
||||
'route' => 'app_administrative_questionnaire_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Fragebögen der Busbegleitungen',
|
||||
'title' => 'Fragebögen nach Einsätzen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'bus',
|
||||
'icon' => 'document',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_questionnaire_/'],
|
||||
],
|
||||
|
||||
@@ -121,10 +121,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
$menu->addChild('Fragebögen', [
|
||||
'route' => 'app_administrative_questionnaire_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Fragebögen der Busbegleitungen',
|
||||
'title' => 'Fragebögen nach Einsätzen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'bus',
|
||||
'icon' => 'document',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_questionnaire_/'],
|
||||
],
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
/**
|
||||
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
|
||||
* version, and their answers are keyed by Question::$key.
|
||||
@@ -20,6 +22,26 @@ readonly class Definition
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The questionnaire as one subject gets it: without the sections marked `only_for` a flag the
|
||||
* subject does not have. Key and version stay, so answers to it are answers to this definition.
|
||||
*
|
||||
* @param list<QuestionnaireFlag> $flags
|
||||
*/
|
||||
public function forFlags(array $flags): static
|
||||
{
|
||||
return new static(
|
||||
$this->key,
|
||||
$this->version,
|
||||
$this->title,
|
||||
$this->description,
|
||||
array_values(array_filter(
|
||||
$this->sections,
|
||||
static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Question> in the order they are asked, keyed by question key
|
||||
*/
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
readonly class Section
|
||||
{
|
||||
/**
|
||||
* @param list<Question> $questions
|
||||
* @param list<Question> $questions
|
||||
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public ?string $title,
|
||||
public array $questions,
|
||||
public ?QuestionnaireFlag $onlyFor = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,10 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is
|
||||
* The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is
|
||||
* still to be submitted: not started, or saved as a draft. The rules are those of
|
||||
* DispositionVoter::QUESTIONNAIRE - bus escort, 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 go-live, neither called
|
||||
* off nor skip-formalities - so every row shown on the dashboard can be opened.
|
||||
*
|
||||
* The response is joined as an arbitrary entity because the association is one-way; the
|
||||
* questionnaire key has to be part of the JOIN condition, or a response to another
|
||||
@@ -107,8 +107,6 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
|
||||
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
|
||||
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
|
||||
$qb->expr()->isNotNull('assignment.pickup'),
|
||||
$qb->expr()->neq('assignment.pickup', 0),
|
||||
$qb->expr()->gte('destination.dateTo', ':goLive'),
|
||||
$qb->expr()->lte('destination.dateTo', ':today'),
|
||||
$qb->expr()->orX(
|
||||
@@ -118,7 +116,7 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
))
|
||||
->orderBy('destination.dateTo', \SortDirection::Descending)
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT)
|
||||
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
|
||||
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->setParameter('skipFormalities', false)
|
||||
|
||||
@@ -118,8 +118,10 @@ class DispositionVoter extends Voter
|
||||
|
||||
/**
|
||||
* Whether the teamer may fill in, and later read, the questionnaire after this placement
|
||||
* (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the
|
||||
* submitted responses through ROLE_ADMINISTRATIVE, not through this attribute.
|
||||
* (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
|
||||
* escort gets the bus sections as well, which is the form's business, not this voter's. Only
|
||||
* the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE,
|
||||
* not through this attribute.
|
||||
*
|
||||
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
|
||||
* run late, and it moves on to checking_invoice and completed while the date stays true. An
|
||||
@@ -137,12 +139,11 @@ class DispositionVoter extends Voter
|
||||
}
|
||||
|
||||
if (true === $disposition->isSkipFormalities()
|
||||
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()
|
||||
|| true !== $disposition->getAssignment()?->hasBusEscort()) {
|
||||
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dateTo = $disposition->getAssignment()->getDestination()?->getDateTo();
|
||||
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
|
||||
|
||||
return null !== $dateTo
|
||||
&& $dateTo >= $this->questionnaireGoLive
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Service\Questionnaire;
|
||||
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
/**
|
||||
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
|
||||
@@ -13,6 +15,17 @@ use App\Entity\Disposition;
|
||||
*/
|
||||
final class DispositionContext
|
||||
{
|
||||
/**
|
||||
* Which `only_for` sections of the questionnaire apply to a placement on this assignment.
|
||||
* Takes the assignment so the office's test mode can ask before anyone is placed.
|
||||
*
|
||||
* @return list<QuestionnaireFlag>
|
||||
*/
|
||||
public static function flags(Assignment $assignment): array
|
||||
{
|
||||
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* teamerName: ?string,
|
||||
|
||||
Reference in new issue
Block a user