refactor: split the questionnaire into teamer and bus feedback

This commit is contained in:
fromme committed 2026-10-27 17:22:22 +01:00
1 parent 2f4f9800dd
commit a0170f5952
35 files changed
+1114 -554

No files matched your search

+29 -29
View File
@@ -4,7 +4,6 @@ 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;
@@ -27,12 +26,17 @@ use Symfony\Component\Yaml\Yaml;
class QuestionnaireCatalog
{
/**
* The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
*/
public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
public const string TEAMER_FEEDBACK = 'teamer_feedback';
/**
* The one a teamer who escorted the bus fills in as well, on the same page.
*/
public const string BUS_FEEDBACK = 'bus_feedback';
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label'];
@@ -80,6 +84,26 @@ class QuestionnaireCatalog
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
}
/**
* The keys of the questionnaires there are, i.e. of their current files.
*
* @return list<string>
*/
public function keys(): array
{
$keys = [];
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
$keys[] = $match[1];
}
}
sort($keys);
return $keys;
}
/**
* Every definition in the directory, archived versions included.
*
@@ -130,8 +154,6 @@ 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);
@@ -149,38 +171,16 @@ 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,
$onlyFor,
);
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
}
return new Definition(
@@ -5,12 +5,14 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\QuestionnaireResponse;
use App\Repository\QuestionnaireResponseRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
@@ -23,10 +25,12 @@ class DetailController extends AbstractController
public function __construct(
private readonly QuestionnaireCatalog $catalog,
private readonly QuestionnaireResponseRepository $responseRepository,
) {
}
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail')]
// the requirement keeps /administrative/questionnaire/list from being read as a uuid
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
@@ -38,9 +42,19 @@ class DetailController extends AbstractController
throw new NotFoundHttpException();
}
// the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback
$siblings = array_filter(
$this->responseRepository->findByDisposition($response->getDisposition()),
static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(),
);
return $this->render('administrative/questionnaire/detail.html.twig', [
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
'response' => $response,
'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [
'response' => $sibling,
'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title,
], array_values($siblings)),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'),
]);
}
@@ -13,8 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The submitted teamer questionnaires. Only administrative users read them: they rate the house
* manager among others, so house managers are deliberately not let in.
* The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users
* read them: they rate the house manager among others, so house managers are deliberately not
* let in.
*/
class IndexController extends AbstractController
{
@@ -26,14 +27,25 @@ class IndexController extends AbstractController
) {
}
#[Route('/administrative/questionnaire', name: 'app_administrative_questionnaire_index')]
#[Route(
'/administrative/questionnaire/list/{questionnaire}',
name: 'app_administrative_questionnaire_index',
requirements: ['questionnaire' => '[a-z0-9_]+'],
defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK],
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
public function index(string $questionnaire, Request $request): Response
{
$keys = $this->catalog->keys();
if (!in_array($questionnaire, $keys, true)) {
throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire));
}
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
$this->responseRepository->getListQuery($questionnaire, $filterDto),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -43,7 +55,9 @@ class IndexController extends AbstractController
);
return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
'definition' => $this->catalog->get($questionnaire),
// for switching between the questionnaires
'definitions' => array_map($this->catalog->get(...), $keys),
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
@@ -5,8 +5,8 @@ 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 App\Model\Questionnaire\Definition;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -15,10 +15,11 @@ 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 - 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.
* The questionnaires as a teamer gets them, for trying out on any assignment - before or after the
* trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback,
* and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is
* saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them
* in the form.
*/
class TestController extends AbstractController
{
@@ -39,12 +40,13 @@ class TestController extends AbstractController
$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] : [])
;
$definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)];
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
if ($busEscort) {
$definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK);
}
$form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]);
$form->handleRequest($request);
$submitted = null;
@@ -52,7 +54,10 @@ class TestController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
if (QuestionnaireType::isFinal($form)) {
$submitted = $definition->withoutHiddenAnswers($form->getData());
$submitted = array_map(static fn (Definition $definition): array => [
'definition' => $definition,
'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []),
], $definitions);
} else {
$draft = true;
}
@@ -60,7 +65,6 @@ class TestController extends AbstractController
return $this->render('administrative/questionnaire/test.html.twig', [
'assignment' => $assignment,
'definition' => $definition,
'form' => $form,
'submitted' => $submitted,
'draft' => $draft,
@@ -2,6 +2,7 @@
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\QuestionnaireResponseFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\QuestionnaireResponseFilterHandler;
@@ -11,8 +12,13 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* One filter for both questionnaire lists; it returns to the list it was opened from.
*/
class QuestionnaireResponseFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler)
{
}
@@ -28,7 +34,7 @@ class QuestionnaireResponseFilterController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
return new HxRedirectResponse($this->generateUrl('app_administrative_questionnaire_index'));
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
return $this->render('common/modal_questionnaire_response_filter.html.twig', [
@@ -39,10 +45,10 @@ class QuestionnaireResponseFilterController extends AbstractController
#[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(): Response
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
return $this->redirectToRoute('app_administrative_questionnaire_index');
return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
}
@@ -5,6 +5,7 @@ namespace App\Controller\Teamer\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Disposition;
use App\Repository\QuestionnaireResponseRepository;
use App\Service\Questionnaire\DispositionContext;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -12,8 +13,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's own submitted questionnaire, read-only, rendered with the definition version it
* was submitted under.
* The teamer's own submitted questionnaires for a placement, read-only, each rendered with the
* definition version it was submitted under.
*/
class DetailController extends AbstractController
{
@@ -30,16 +31,34 @@ class DetailController extends AbstractController
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
$submitted = [];
$open = false;
$responses = $this->responseRepository->findByDisposition($disposition);
if (true !== $response?->isSubmitted()) {
// what applies now, plus anything submitted under an earlier state of the assignment
$keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]);
foreach ($keys as $key) {
$response = $responses[$key] ?? null;
if (true === $response?->isSubmitted()) {
$submitted[] = [
'definition' => $this->catalog->get($key, $response->getVersion()),
'response' => $response,
];
} else {
$open = true;
}
}
if ([] === $submitted) {
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
}
return $this->render('teamer/questionnaire/detail.html.twig', [
'disposition' => $disposition,
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
'response' => $response,
'submitted' => $submitted,
'open' => $open,
]);
}
}
@@ -18,9 +18,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* 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
* The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus
* escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them,
* each as a response of its own. Submitted ones are final and only shown read-only
* (DetailController).
*
* Only the questionnaires not yet submitted are offered, so one that applies later - an escort
* added after the teamer submitted - can still be filled in.
*/
class EditController extends AbstractController
{
@@ -40,45 +44,60 @@ class EditController extends AbstractController
Disposition $disposition,
Request $request,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
$responses = $this->responseRepository->findByDisposition($disposition);
$definitions = [];
if (true === $response?->isSubmitted()) {
foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) {
if (true !== ($responses[$key] ?? null)?->isSubmitted()) {
$definitions[] = $this->catalog->get($key);
}
}
if ([] === $definitions) {
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
}
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags(DispositionContext::flags($disposition->getAssignment()))
;
$data = [];
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
'definition' => $definition,
]);
foreach ($definitions as $definition) {
$data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? [];
}
$form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (null === $response) {
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
$this->entityManager->persist($response);
}
$context = DispositionContext::build($disposition);
$final = QuestionnaireType::isFinal($form);
if (true === $final) {
$response->submit($definition, $form->getData(), $context, new \DateTimeImmutable());
} else {
$response->saveDraft($form->getData(), $context);
foreach ($definitions as $definition) {
$response = $responses[$definition->key] ?? null;
if (null === $response) {
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
$this->entityManager->persist($response);
$responses[$definition->key] = $response;
}
$answers = $form->getData()[$definition->key] ?? [];
if (true === $final) {
$response->submit($definition, $answers, $context, new \DateTimeImmutable());
} else {
$response->saveDraft($answers, $context);
}
}
$this->entityManager->flush();
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
'questionnaire' => $response->getQuestionnaire(),
'questionnaire_response_id' => $response->getId(),
'disposition_id' => $disposition->getId(),
'teamer_id' => $disposition->getTeamer()->getId(),
]);
foreach ($definitions as $definition) {
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
'questionnaire' => $definition->key,
'questionnaire_response_id' => $responses[$definition->key]->getId(),
'disposition_id' => $disposition->getId(),
'teamer_id' => $disposition->getTeamer()->getId(),
]);
}
if (true === $final) {
$this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.');
@@ -91,11 +110,15 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
}
$drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted());
return $this->render('teamer/questionnaire/edit.html.twig', [
'disposition' => $disposition,
'definition' => $definition,
'response' => $response,
'form' => $form,
'lastSaved' => [] === $drafts ? null : max(array_map(
static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(),
$drafts,
)),
]);
}
}
-13
View File
@@ -1,13 +0,0 @@
<?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';
}
+47 -29
View File
@@ -21,8 +21,10 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Builds a questionnaire form from its definition. The data is the flat answers array keyed by
* question key; each section is a child with inherit_data, so it groups the fields on screen
* Builds one form from one or more questionnaire definitions - a teamer after a bus escort answers
* teamer_feedback and bus_feedback on one page. The data is keyed by questionnaire key, each
* holding that questionnaire's flat answers keyed by question key: every questionnaire is a child
* of its own, and each of its sections a child with inherit_data, which groups the fields on screen
* without nesting the answers.
*
* Two buttons: "draft" saves whatever is filled in, "submit" also demands every required question
@@ -39,21 +41,32 @@ class QuestionnaireType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var Definition $definition */
$definition = $options['definition'];
/** @var list<Definition> $definitions */
$definitions = $options['definitions'];
foreach ($definition->sections as $section) {
$sectionBuilder = $builder->create($section->key, FormType::class, [
'inherit_data' => true,
'label' => $section->title ?? false,
'block_prefix' => 'questionnaire_section',
foreach ($definitions as $definition) {
$questionnaireBuilder = $builder->create($definition->key, FormType::class, [
'label' => $definition->title,
'help' => $definition->description,
'empty_data' => [],
'block_prefix' => 'questionnaire_part',
]);
foreach ($section->questions as $question) {
$sectionBuilder->add($question->key, ...$this->field($question));
foreach ($definition->sections as $section) {
$sectionBuilder = $builder->create($section->key, FormType::class, [
'inherit_data' => true,
'label' => $section->title ?? false,
'block_prefix' => 'questionnaire_section',
]);
foreach ($section->questions as $question) {
$sectionBuilder->add($question->key, ...$this->field($definition, $question));
}
$questionnaireBuilder->add($sectionBuilder);
}
$builder->add($sectionBuilder);
$builder->add($questionnaireBuilder);
}
$builder
@@ -63,24 +76,26 @@ class QuestionnaireType extends AbstractType
->add(self::SUBMIT, SubmitType::class, [
'label' => 'Absenden',
])
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definition): void {
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definitions): void {
$form = $event->getForm();
if (!$this->isFinal($form)) {
return;
}
$answers = $form->getData();
foreach ($definitions as $definition) {
$answers = $form->getData()[$definition->key] ?? [];
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
if ($question->required
&& $definition->isVisible($question, $answers)
&& $this->isEmpty($answers[$question->key] ?? null)
) {
$form->get($section->key)->get($question->key)->addError(
new FormError('Bitte beantworte diese Frage.')
);
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
if ($question->required
&& $definition->isVisible($question, $answers)
&& $this->isEmpty($answers[$question->key] ?? null)
) {
$form->get($definition->key)->get($section->key)->get($question->key)->addError(
new FormError('Bitte beantworte diese Frage.')
);
}
}
}
}
@@ -91,8 +106,10 @@ class QuestionnaireType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired('definition')
->setAllowedTypes('definition', Definition::class)
->setRequired('definitions')
->setAllowedTypes('definitions', Definition::class.'[]')
->setAllowedValues('definitions', static fn (array $definitions): bool => [] !== $definitions
&& count($definitions) === count(array_unique(array_map(static fn (Definition $definition): string => $definition->key, $definitions))))
->setDefaults([
'data_class' => null,
'empty_data' => [],
@@ -114,16 +131,17 @@ class QuestionnaireType extends AbstractType
/**
* @return array{0: class-string, 1: array<string, mixed>}
*/
private function field(Question $question): array
private function field(Definition $definition, Question $question): array
{
$common = [
'label' => $question->label,
'help' => $question->help,
'required' => false,
// read by assets/controllers/questionnaire_controller.js
'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [
// read by assets/controllers/questionnaire_controller.js; prefixed with the questionnaire,
// since question keys are only unique within one
'row_attr' => ['data-question-key' => $definition->key.'.'.$question->key] + (null === $question->showIf ? [] : [
'data-show-if' => json_encode([
'question' => $question->showIf->question,
'question' => $definition->key.'.'.$question->showIf->question,
'operator' => $question->showIf->operator->value,
'value' => $question->showIf->value,
], JSON_THROW_ON_ERROR),
-22
View File
@@ -2,8 +2,6 @@
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.
@@ -22,26 +20,6 @@ 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
*/
+1 -5
View File
@@ -2,19 +2,15 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
readonly class Section
{
/**
* @param list<Question> $questions
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
* @param list<Question> $questions
*/
public function __construct(
public string $key,
public ?string $title,
public array $questions,
public ?QuestionnaireFlag $onlyFor = null,
) {
}
}
+32 -13
View File
@@ -77,30 +77,38 @@ class DispositionRepository extends ServiceEntityRepository
}
/**
* 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
* 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.
*
* 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
* questionnaire would hide this one.
* 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
* would stand in for the other's.
*
* Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus].
* Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus,
* 'busStatus' => ?QuestionnaireResponseStatus].
*/
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query
{
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus')
->select('disposition', 'assignment', 'destination', 'teamer_response.status AS teamerStatus', 'bus_response.status AS busStatus')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin(
QuestionnaireResponse::class,
'response',
'teamer_response',
Join::ON,
'response.disposition = disposition AND response.questionnaire = :questionnaire'
'teamer_response.disposition = disposition AND teamer_response.questionnaire = :teamerQuestionnaire'
)
->leftJoin(
QuestionnaireResponse::class,
'bus_response',
Join::ON,
'bus_response.disposition = disposition AND bus_response.questionnaire = :busQuestionnaire'
)
->where($qb->expr()->andX(
$qb->expr()->eq('disposition.teamer', ':teamer'),
@@ -110,13 +118,23 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->gte('destination.dateTo', ':goLive'),
$qb->expr()->lte('destination.dateTo', ':today'),
$qb->expr()->orX(
$qb->expr()->isNull('response.id'),
$qb->expr()->eq('response.status', ':draft')
$qb->expr()->isNull('teamer_response.id'),
$qb->expr()->eq('teamer_response.status', ':draft'),
// a bus escort, i.e. Assignment::hasBusEscort(), owing the bus feedback
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.pickup'),
$qb->expr()->neq('assignment.pickup', 0),
$qb->expr()->orX(
$qb->expr()->isNull('bus_response.id'),
$qb->expr()->eq('bus_response.status', ':draft')
)
)
)
))
->orderBy('destination.dateTo', \SortDirection::Descending)
->setParameter('teamer', $teamer)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->setParameter('teamerQuestionnaire', QuestionnaireCatalog::TEAMER_FEEDBACK)
->setParameter('busQuestionnaire', QuestionnaireCatalog::BUS_FEEDBACK)
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false)
@@ -135,7 +153,8 @@ class DispositionRepository extends ServiceEntityRepository
return array_map(
static fn (array $row): array => [
'disposition' => $row[0],
'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'],
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|| QuestionnaireResponseStatus::DRAFT === $row['busStatus'],
],
$this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(),
);
@@ -80,8 +80,17 @@ class QuestionnaireResponseRepository extends ServiceEntityRepository
return $qb->getQuery();
}
public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse
/**
* @return array<string, QuestionnaireResponse> keyed by questionnaire key
*/
public function findByDisposition(Disposition $disposition): array
{
return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]);
$responses = [];
foreach ($this->findBy(['disposition' => $disposition]) as $response) {
$responses[$response->getQuestionnaire()] = $response;
}
return $responses;
}
}
+5 -5
View File
@@ -117,11 +117,11 @@ class DispositionVoter extends Voter
}
/**
* Whether the teamer may fill in, and later read, the questionnaire after this placement
* (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.
* Whether the teamer may fill in, and later read, the questionnaires after this placement:
* every placement gets the teamer feedback, a bus escort the bus feedback as well
* (DispositionContext::questionnaires() - which ones is not this voter's business). 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
@@ -2,9 +2,9 @@
namespace App\Service\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\QuestionnaireFlag;
/**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
@@ -16,14 +16,19 @@ use App\Enum\QuestionnaireFlag;
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.
* The questionnaires a placement on this assignment gets, in the order they are shown: every
* teamer the teamer feedback, a bus escort the bus feedback as well. Takes the assignment so
* the office's test mode can ask before anyone is placed.
*
* @return list<QuestionnaireFlag>
* DispositionRepository::getPendingQuestionnaireQuery() spells the same rule in DQL.
*
* @return list<string> questionnaire keys
*/
public static function flags(Assignment $assignment): array
public static function questionnaires(Assignment $assignment): array
{
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
return $assignment->hasBusEscort()
? [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK]
: [QuestionnaireCatalog::TEAMER_FEEDBACK];
}
/**