feat: store questionnaire responses per disposition
This commit is contained in:
1 parent
8070fda64d
commit
df6734c939
9 files changed
+521
-1
No files matched your search
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds questionnaire_response: a respondent's answers to one questionnaire from
|
||||
* config/questionnaires/, for now the teamer questionnaire after a bus-escort disposition.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261027140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add questionnaire_response';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE questionnaire_response (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, questionnaire VARCHAR(64) NOT NULL, version INT NOT NULL, status VARCHAR(16) NOT NULL, submitted_at DATETIME DEFAULT NULL, answers JSON NOT NULL, context JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, disposition_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A0400276D17F50A6 (uuid), UNIQUE INDEX questionnaire_disposition (questionnaire, disposition_id), INDEX IDX_A0400276287B65ED (disposition_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
|
||||
$this->addSql('ALTER TABLE questionnaire_response ADD CONSTRAINT FK_A0400276287B65ED FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE questionnaire_response DROP FOREIGN KEY FK_A0400276287B65ED');
|
||||
$this->addSql('DROP TABLE questionnaire_response');
|
||||
}
|
||||
}
|
||||
@@ -256,13 +256,22 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer escorts the bus: the "Busbegleitung ab" pickup is set. 0 is stored for
|
||||
* "Keine Busbegleitung" as well as null, so this is not a plain null check.
|
||||
*/
|
||||
public function hasBusEscort(): bool
|
||||
{
|
||||
return 0 !== (int) $this->pickup;
|
||||
}
|
||||
|
||||
public function getEffectivePickupDate(): ?\DateTimeImmutable
|
||||
{
|
||||
if (null !== $this->pickupDate) {
|
||||
return $this->pickupDate;
|
||||
}
|
||||
|
||||
if (0 !== (int) $this->pickup && null !== $destination = $this->getDestination()) {
|
||||
if ($this->hasBusEscort() && null !== $destination = $this->getDestination()) {
|
||||
return $destination
|
||||
->getDateFrom()
|
||||
->modify('-1 day')
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Enum\QuestionnaireResponseStatus;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* One respondent's answers to one questionnaire (config/questionnaires/), a draft until submitted.
|
||||
*
|
||||
* Answers are keyed by question key and only mean something together with the definition of
|
||||
* $questionnaire in $version. $context holds what the subject already knew - for a disposition,
|
||||
* who, where, when and the escort pickup - so those questions are not asked and the response stays
|
||||
* readable when the assignment is edited later.
|
||||
*
|
||||
* Its subject is a Disposition today. A questionnaire about something else gets a nullable foreign
|
||||
* key of its own beside $disposition, not a generic subject column.
|
||||
*
|
||||
* Not soft-deletable: nothing in the UI deletes a response, and it goes with its disposition.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: QuestionnaireResponseRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\UniqueConstraint(name: 'questionnaire_disposition', columns: ['questionnaire', 'disposition_id'])]
|
||||
class QuestionnaireResponse implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $questionnaire;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $version;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: QuestionnaireResponseStatus::class)]
|
||||
private QuestionnaireResponseStatus $status = QuestionnaireResponseStatus::DRAFT;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $submittedAt = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $answers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $context = [];
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE')]
|
||||
private ?Disposition $disposition = null;
|
||||
|
||||
private function __construct(Definition $definition)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->questionnaire = $definition->key;
|
||||
$this->version = $definition->version;
|
||||
}
|
||||
|
||||
public static function forDisposition(Definition $definition, Disposition $disposition): static
|
||||
{
|
||||
$instance = new static($definition);
|
||||
$instance->disposition = $disposition;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps every answer, including those to questions that are hidden right now: switching an
|
||||
* answer back restores the follow-up already written.
|
||||
*
|
||||
* @param array<string, mixed> $answers
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function saveDraft(array $answers, array $context): static
|
||||
{
|
||||
$this->assertDraft();
|
||||
|
||||
$this->answers = $answers;
|
||||
$this->context = $context;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final. Keeps only the answers to questions that are shown, and records the definition version
|
||||
* they were given under - the draft may have been started under an older one.
|
||||
*
|
||||
* @param array<string, mixed> $answers
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function submit(Definition $definition, array $answers, array $context, \DateTimeImmutable $now): static
|
||||
{
|
||||
$this->assertDraft();
|
||||
|
||||
if ($definition->key !== $this->questionnaire) {
|
||||
throw new \LogicException(sprintf('Response to "%s" cannot be submitted with "%s"', $this->questionnaire, $definition->key));
|
||||
}
|
||||
|
||||
$this->answers = $definition->withoutHiddenAnswers($answers);
|
||||
$this->context = $context;
|
||||
$this->version = $definition->version;
|
||||
$this->status = QuestionnaireResponseStatus::SUBMITTED;
|
||||
$this->submittedAt = $now;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSubmitted(): bool
|
||||
{
|
||||
return QuestionnaireResponseStatus::SUBMITTED === $this->status;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getQuestionnaire(): string
|
||||
{
|
||||
return $this->questionnaire;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function getStatus(): QuestionnaireResponseStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAnswers(): array
|
||||
{
|
||||
return $this->answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
public function getDisposition(): ?Disposition
|
||||
{
|
||||
return $this->disposition;
|
||||
}
|
||||
|
||||
private function assertDraft(): void
|
||||
{
|
||||
if ($this->isSubmitted()) {
|
||||
throw new \LogicException(sprintf('Questionnaire response %s is already submitted', $this->uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum QuestionnaireResponseStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case SUBMITTED = 'submitted';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'Entwurf',
|
||||
self::SUBMITTED => 'Abgeschickt',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<QuestionnaireResponse>
|
||||
*
|
||||
* @method QuestionnaireResponse|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method QuestionnaireResponse|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method QuestionnaireResponse[] findAll()
|
||||
* @method QuestionnaireResponse[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class QuestionnaireResponseRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionnaireResponse::class);
|
||||
}
|
||||
|
||||
public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse
|
||||
{
|
||||
return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Questionnaire;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
|
||||
* need not ask it: who, in which role, where, when, and where the bus escort started.
|
||||
*
|
||||
* Kept as plain scalars (dates as Y-m-d), because it is stored as JSON and must stay readable
|
||||
* after the assignment, its destination or the teamer change.
|
||||
*/
|
||||
final class DispositionContext
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* teamerName: ?string,
|
||||
* jobProfileName: ?string,
|
||||
* jobProfileCategory: ?string,
|
||||
* destinationProduct: ?string,
|
||||
* hotel: ?string,
|
||||
* hotelCode: ?string,
|
||||
* dateFrom: ?string,
|
||||
* dateTo: ?string,
|
||||
* escortPickup: array{busProId: ?int, city: ?string, street: ?string, time: ?string}|null,
|
||||
* escortPickupDate: ?string,
|
||||
* }
|
||||
*/
|
||||
public static function build(Disposition $disposition): array
|
||||
{
|
||||
$assignment = $disposition->getAssignment();
|
||||
$destination = $assignment?->getDestination();
|
||||
$jobProfile = $assignment?->getJobProfile();
|
||||
$pickup = $assignment?->hasBusEscort() ? $destination?->getPickup($assignment->getPickup()) : null;
|
||||
|
||||
return [
|
||||
'teamerName' => $disposition->getTeamer()?->getFullName(),
|
||||
'jobProfileName' => $jobProfile?->getName(),
|
||||
'jobProfileCategory' => $jobProfile?->getCategory(),
|
||||
'destinationProduct' => $destination?->getProduct(),
|
||||
'hotel' => $destination?->getHotel(),
|
||||
'hotelCode' => $destination?->getHotelCode(),
|
||||
'dateFrom' => $destination?->getDateFrom()?->format('Y-m-d'),
|
||||
'dateTo' => $destination?->getDateTo()?->format('Y-m-d'),
|
||||
'escortPickup' => null === $pickup ? null : [
|
||||
// Pickup::fromArray() fills id, not busProId; the assignment holds the same value
|
||||
'busProId' => $assignment->getPickup(),
|
||||
'city' => $pickup->getCity(),
|
||||
'street' => $pickup->getStreet(),
|
||||
'time' => $pickup->getTime(),
|
||||
],
|
||||
'escortPickupDate' => $assignment?->getEffectivePickupDate()?->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Teamer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,23 @@ class AssignmentTest extends TestCase
|
||||
$this->assertSame($disposition, $assignment->getActiveDispositionByTeamer($teamer));
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Busbegleitung ab" field stores 0 for "Keine Busbegleitung" as well as null.
|
||||
*/
|
||||
#[DataProvider('providePickups')]
|
||||
public function testHasBusEscort(int|string|null $pickup, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, (new Assignment())->setPickup($pickup)->hasBusEscort());
|
||||
}
|
||||
|
||||
public static function providePickups(): iterable
|
||||
{
|
||||
yield 'none' => [null, false];
|
||||
yield 'zero' => [0, false];
|
||||
yield 'empty form value' => ['', false];
|
||||
yield 'pickup' => [4711, true];
|
||||
}
|
||||
|
||||
private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition
|
||||
{
|
||||
$disposition = new Disposition(new Application($assignment, $teamer));
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionnaireResponseStatus;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use App\Model\Questionnaire\Section;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class QuestionnaireResponseTest extends TestCase
|
||||
{
|
||||
public function testNewResponseIsADraftOfTheCurrentVersion(): void
|
||||
{
|
||||
$response = $this->response();
|
||||
|
||||
$this->assertSame('sample', $response->getQuestionnaire());
|
||||
$this->assertSame(1, $response->getVersion());
|
||||
$this->assertSame(QuestionnaireResponseStatus::DRAFT, $response->getStatus());
|
||||
$this->assertNull($response->getSubmittedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* The respondent switched "damage" back to "no" after writing a description; switching it to
|
||||
* "yes" again must bring the description back.
|
||||
*/
|
||||
public function testDraftKeepsAnswersToHiddenQuestions(): void
|
||||
{
|
||||
$response = $this->response()->saveDraft(['damage' => 'no', 'details' => 'Spiegel ab'], ['teamerName' => 'Kim']);
|
||||
|
||||
$this->assertSame(['damage' => 'no', 'details' => 'Spiegel ab'], $response->getAnswers());
|
||||
$this->assertSame(['teamerName' => 'Kim'], $response->getContext());
|
||||
$this->assertFalse($response->isSubmitted());
|
||||
}
|
||||
|
||||
public function testSubmitDropsAnswersToHiddenQuestions(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable('2026-03-01 12:00');
|
||||
$response = $this->response()->submit($this->definition(), ['damage' => 'no', 'details' => 'Spiegel ab'], [], $now);
|
||||
|
||||
$this->assertSame(['damage' => 'no'], $response->getAnswers());
|
||||
$this->assertTrue($response->isSubmitted());
|
||||
$this->assertSame($now, $response->getSubmittedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft begun before the definition was bumped is submitted, and therefore rendered, under
|
||||
* the version it was completed in.
|
||||
*/
|
||||
public function testSubmitRecordsTheVersionAnsweredUnder(): void
|
||||
{
|
||||
$response = $this->response()->submit($this->definition(version: 2), ['damage' => 'no'], [], new \DateTimeImmutable());
|
||||
|
||||
$this->assertSame(2, $response->getVersion());
|
||||
}
|
||||
|
||||
public function testSubmittedResponseCannotBeChanged(): void
|
||||
{
|
||||
$response = $this->response()->submit($this->definition(), ['damage' => 'no'], [], new \DateTimeImmutable());
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
$response->saveDraft(['damage' => 'yes'], []);
|
||||
}
|
||||
|
||||
public function testResponseCannotBeSubmittedWithAnotherQuestionnaire(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
$this->response()->submit($this->definition(key: 'other'), [], [], new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
private function response(): QuestionnaireResponse
|
||||
{
|
||||
$disposition = new Disposition(new Application(new Assignment(), new Teamer()));
|
||||
|
||||
return QuestionnaireResponse::forDisposition($this->definition(), $disposition);
|
||||
}
|
||||
|
||||
private function definition(string $key = 'sample', int $version = 1): Definition
|
||||
{
|
||||
return new Definition($key, $version, 'Beispiel', null, [
|
||||
new Section('main', null, [
|
||||
new Question('damage', 'Schäden?', QuestionType::CHOICE, options: [new Option('yes', 'Ja'), new Option('no', 'Nein')]),
|
||||
new Question('details', 'Was ist passiert?', QuestionType::TEXTAREA, showIf: new Condition('damage', ConditionOperator::EQUALS, 'yes')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Questionnaire;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Destination;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Entity\Teamer;
|
||||
use App\Service\Questionnaire\DispositionContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DispositionContextTest extends TestCase
|
||||
{
|
||||
public function testEscortDispositionIsSnapshotted(): void
|
||||
{
|
||||
$context = DispositionContext::build($this->disposition(pickup: 4711));
|
||||
|
||||
$this->assertSame([
|
||||
'teamerName' => 'Kim Muster',
|
||||
'jobProfileName' => 'Reiseleitung',
|
||||
'jobProfileCategory' => JobProfile::CATEGORY_GUIDE,
|
||||
'destinationProduct' => 'Davos Klosters - Schwendi',
|
||||
'hotel' => 'Schwendi',
|
||||
'hotelCode' => 'DKI',
|
||||
'dateFrom' => '2026-01-10',
|
||||
'dateTo' => '2026-01-17',
|
||||
'escortPickup' => ['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30'],
|
||||
// the escort leaves the evening before arrival
|
||||
'escortPickupDate' => '2026-01-09',
|
||||
], $context);
|
||||
}
|
||||
|
||||
public function testDispositionWithoutEscortHasNoPickup(): void
|
||||
{
|
||||
$context = DispositionContext::build($this->disposition(pickup: 0));
|
||||
|
||||
$this->assertNull($context['escortPickup']);
|
||||
$this->assertNull($context['escortPickupDate']);
|
||||
}
|
||||
|
||||
private function disposition(int $pickup): Disposition
|
||||
{
|
||||
$destination = (new Destination())
|
||||
->setProduct('Davos Klosters - Schwendi')
|
||||
->setHotel('Schwendi')
|
||||
->setHotelCode('DKI')
|
||||
->setDateFrom(new \DateTimeImmutable('2026-01-10'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-01-17'))
|
||||
->setPickups([
|
||||
['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30'],
|
||||
['busProId' => 4712, 'city' => 'Ulm', 'street' => 'ZOB', 'time' => '22:45'],
|
||||
])
|
||||
;
|
||||
$assignment = (new Assignment())
|
||||
->setDestination($destination)
|
||||
->setJobProfile((new JobProfile())->setName('Reiseleitung')->setCategory(JobProfile::CATEGORY_GUIDE))
|
||||
->setPickup($pickup)
|
||||
;
|
||||
$teamer = (new Teamer())->setFirstName('Kim')->setLastName('Muster');
|
||||
|
||||
return new Disposition(new Application($assignment, $teamer));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user