Skip to content
Open
5 changes: 5 additions & 0 deletions api/fixtures/camps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ App\Entity\Camp:
creator: '@user2member'
isPrototype: false
isShared: false
hasChecklists: true
sharedBy: null
sharedSince: null
campPrototypeId: null
Expand All @@ -26,6 +27,7 @@ App\Entity\Camp:
creator: '@user4unrelated'
isPrototype: false
isShared: false
hasChecklists: true
sharedBy: null
sharedSince: null
campPrototypeId: null
Expand All @@ -41,6 +43,7 @@ App\Entity\Camp:
creator: '@admin'
isPrototype: false
isShared: false
hasChecklists: true
sharedBy: null
sharedSince: null
campPrototypeId: null
Expand All @@ -57,6 +60,7 @@ App\Entity\Camp:
isPublic: true
isPrototype: true
isShared: false
hasChecklists: true
sharedBy: null
sharedSince: null
campPrototypeId: null
Expand All @@ -73,6 +77,7 @@ App\Entity\Camp:
isPublic: true
isPrototype: false
isShared: true
hasChecklists: true
sharedBy: '@admin'
sharedSince: '<(new DateTime("2025-09-03 12:00:00"))>'
campPrototypeId: null
8 changes: 0 additions & 8 deletions api/migrations/dev-data/Version202508132053.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,6 @@ public function getDescription(): string {

public function up(Schema $schema): void {
// START PHP CODE
$this->addSql(createTruncateDatabaseCommand());

$statements = getStatementsForMigrationFile();
foreach ($statements as $statement) {
if (trim($statement)) {
$this->addSql($statement);
}
}
// END PHP CODE
}

Expand Down
33 changes: 33 additions & 0 deletions api/migrations/dev-data/Version202605062223.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace DataMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

require_once __DIR__.'/helpers.php';

final class Version202605062223 extends AbstractMigration {
#[\Override]
public function getDescription(): string {
return 'Add hasChecklist flag';
}

public function up(Schema $schema): void {
// START PHP CODE
$this->addSql(createTruncateDatabaseCommand());

$statements = getStatementsForMigrationFile();
foreach ($statements as $statement) {
if (trim($statement)) {
$this->addSql($statement);
}
}
// END PHP CODE
}

#[\Override]
public function down(Schema $schema): void {}
}
256 changes: 128 additions & 128 deletions api/migrations/dev-data/data.sql

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions api/migrations/schema/Version20260506181000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260506181000 extends AbstractMigration {
#[\Override]
public function getDescription(): string {
return 'Add persisted hasChecklists flag on camps';
}

public function up(Schema $schema): void {
$this->addSql('ALTER TABLE camp ADD hasChecklists BOOLEAN DEFAULT false NOT NULL');
$this->addSql(<<<'SQL'
UPDATE camp
SET hasChecklists = true
WHERE EXISTS (
SELECT 1
FROM checklist
WHERE checklist.campId = camp.id
AND checklist.isPrototype = false
)
SQL);
}

#[\Override]
public function down(Schema $schema): void {
$this->addSql('ALTER TABLE camp DROP hasChecklists');
}
}
11 changes: 11 additions & 0 deletions api/src/Entity/Camp.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy
#[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])]
public bool $isPublic = false;

/**
* Whether this camp uses checklists.
*/
#[Assert\Type('bool')]
#[Assert\DisableAutoMapping]
#[ApiProperty(writable: false, example: true)]
#[Groups(['read'])]
#[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])]
public bool $hasChecklists = false;

/**
* An optional short title for the camp. Can be used in the UI where space is tight. If
* not present, frontends may auto-shorten the title if the shortTitle is not set.
Expand Down Expand Up @@ -721,6 +731,7 @@ public function addChecklist(Checklist $checklist): self {
if (!$this->checklists->contains($checklist)) {
$this->checklists[] = $checklist;
$checklist->camp = $this;
$this->hasChecklists = true;
Comment thread
manuelmeister marked this conversation as resolved.
Outdated
}

return $this;
Expand Down
2 changes: 2 additions & 0 deletions api/src/Entity/Checklist.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\InputFilter;
use App\Repository\ChecklistRepository;
use App\State\ChecklistCreateProcessor;
use App\State\ChecklistRemoveProcessor;
use App\Util\EntityMap;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
Expand Down Expand Up @@ -45,6 +46,7 @@
',
validationContext: ['groups' => ['delete']],
validate: true,
processor: ChecklistRemoveProcessor::class,
),
new GetCollection(
security: 'is_authenticated()'
Expand Down
4 changes: 4 additions & 0 deletions api/src/State/ChecklistCreateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class ChecklistCreateProcessor extends AbstractPersistProcessor {
*/
#[\Override]
public function onBefore($data, Operation $operation, array $uriVariables = [], array $context = []): Checklist {
if (null !== $data->camp && !$data->isPrototype) {
$data->camp->hasChecklists = true;
}

if (null !== $data->copyChecklistSource) {
// CopyChecklist Source is set -> copy it's content
$entityMap = new EntityMap($data->camp);
Expand Down
37 changes: 37 additions & 0 deletions api/src/State/ChecklistRemoveProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Checklist;
use App\State\Util\AbstractRemoveProcessor;
use Doctrine\ORM\EntityManagerInterface;

/**
* @template-extends AbstractRemoveProcessor<Checklist>
*/
class ChecklistRemoveProcessor extends AbstractRemoveProcessor {
public function __construct(
ProcessorInterface $decorated,
private readonly EntityManagerInterface $em
) {
parent::__construct($decorated);
}

/**
* @param Checklist $data
*/
#[\Override]
public function onBefore($data, Operation $operation, array $uriVariables = [], array $context = []): void {
$camp = $data->camp;
if (null === $camp || $data->isPrototype) {
Comment thread
manuelmeister marked this conversation as resolved.
Outdated
return;
}

$camp->hasChecklists = 1 < $this->em->getRepository(Checklist::class)->count([
'camp' => $camp,
Comment thread
manuelmeister marked this conversation as resolved.
Outdated
'isPrototype' => false,
]);
}
}
20 changes: 20 additions & 0 deletions api/tests/Api/Checklists/CreateChecklistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Entity\Camp;
use App\Entity\Checklist;
use App\Tests\Api\ECampApiTestCase;
use App\Tests\Constraints\CompatibleHalResponse;
Expand Down Expand Up @@ -132,6 +133,25 @@ public function testCreateChecklistIsAllowedForManager() {
$this->assertJsonContains($this->getExampleReadPayload());
}

public function testCreateChecklistUpdatesCampHasChecklistsFlag() {
$camp = static::getFixture('camp2');
$camp->hasChecklists = false;
$this->getEntityManager()->flush();

static::createClientWithCredentials()
->request('POST', '/checklists', ['json' => $this->getExampleWritePayload([
'camp' => $this->getIriFor($camp),
'name' => 'New course checklist',
])])
;

$this->assertResponseStatusCodeSame(201);

$this->getEntityManager()->clear();
$camp = $this->getEntityManager()->getRepository(Camp::class)->find($camp->getId());
$this->assertTrue($camp->hasChecklists);
}

public function testCreateChecklistInCampPrototypeIsDeniedForUnrelatedUser() {
static::createClientWithCredentials()->request('POST', '/checklists', ['json' => $this->getExampleWritePayload([
'camp' => $this->getIriFor('campPrototype'),
Expand Down
19 changes: 17 additions & 2 deletions api/tests/Api/Checklists/DeleteChecklistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Tests\Api\Checklists;

use App\Entity\Camp;
use App\Entity\Checklist;
use App\Tests\Api\ECampApiTestCase;

Expand Down Expand Up @@ -111,6 +112,17 @@ public function testDeleteChecklistIsAllowedForManager() {
$this->assertNull($this->getEntityManager()->getRepository(Checklist::class)->find($checklist->getId()));
}

public function testDeleteLastChecklistUpdatesCampHasChecklistsFlag() {
$checklist = static::getFixture('checklist1camp2');
static::createClientWithCredentials()->request('DELETE', '/checklists/'.$checklist->getId());

$this->assertResponseStatusCodeSame(204);

$this->getEntityManager()->clear();
$camp = $this->getEntityManager()->getRepository(Camp::class)->find(static::getFixture('camp2')->getId());
$this->assertFalse($camp->hasChecklists);
}

public function testDeleteChecklistFromCampPrototypeIsDeniedForUnrelatedUser() {
$checklist = static::getFixture('checklist1campPrototype');
static::createClientWithCredentials()->request('DELETE', '/checklists/'.$checklist->getId());
Expand Down Expand Up @@ -161,11 +173,14 @@ public function testDeleteChecklistFromSharedCampIsDeniedForInvitedUser() {

public function testDeleteChecklistIsDeniedWhenUsedInChecklistNode() {
$checklist = static::getFixture('checklist1');
static::createClientWithCredentials()->request('DELETE', '/checklists/'.$checklist->getId());
$response = static::createClientWithCredentials()->request('DELETE', '/checklists/'.$checklist->getId());
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'title' => 'An error occurred',
'detail' => 'checklistItems[0].checklistNodes: It\'s not possible to delete a checklist item as long as checklist nodes are referencing it.',
]);
$this->assertStringContainsString(
'checklistNodes: It\'s not possible to delete a checklist item as long as checklist nodes are referencing it.',
$response->toArray(false)['detail']
);
}
}
Loading
Loading