Skip to content

Commit

Permalink
Merge pull request #5930 from pmattmann/feature/prototype-checklist
Browse files Browse the repository at this point in the history
Checklist Prototypes
  • Loading branch information
usu authored Sep 28, 2024
2 parents c3ab369 + f9c4e25 commit 6886735
Show file tree
Hide file tree
Showing 14 changed files with 342 additions and 36 deletions.
4 changes: 4 additions & 0 deletions api/fixtures/checklists.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ App\Entity\Checklist:
checklist1campPrototype:
camp: '@campPrototype'
name: 'J+S Ausbildungsziele'
checklistPrototype:
camp: null
isPrototype: true
name: 'J+S Ausbildungsziele'
30 changes: 30 additions & 0 deletions api/migrations/schema/Version20240912183023.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240912183023 extends AbstractMigration {
public function getDescription(): string {
return 'Checklist.IsPrototype';
}

public function up(Schema $schema): void {
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE checklist ADD isPrototype BOOLEAN NOT NULL');
$this->addSql('ALTER TABLE checklist ALTER campid DROP NOT NULL');
}

public function down(Schema $schema): void {
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE checklist DROP isPrototype');
$this->addSql('ALTER TABLE checklist ALTER campId SET NOT NULL');
}
}
33 changes: 27 additions & 6 deletions api/src/Entity/Checklist.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,37 @@
#[ApiResource(
operations: [
new Get(
security: 'is_granted("CAMP_COLLABORATOR", object) or is_granted("CAMP_IS_PROTOTYPE", object)'
security: 'is_granted("CHECKLIST_IS_PROTOTYPE", object) or
is_granted("CAMP_IS_PROTOTYPE", object) or
is_granted("CAMP_COLLABORATOR", object)
'
),
new Patch(
security: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)'
security: '(is_granted("CHECKLIST_IS_PROTOTYPE", object) and is_granted("ROLE_ADMIN")) or
(is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object))
'
),
new Delete(
security: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)'
security: '(is_granted("CHECKLIST_IS_PROTOTYPE", object) and is_granted("ROLE_ADMIN")) or
(is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object))
'
),
new GetCollection(
security: 'is_authenticated()'
),
new Post(
processor: ChecklistCreateProcessor::class,
denormalizationContext: ['groups' => ['write', 'create']],
securityPostDenormalize: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object) or object.camp === null'
securityPostDenormalize: '(is_granted("CHECKLIST_IS_PROTOTYPE", object) and is_granted("ROLE_ADMIN")) or
(!is_granted("CHECKLIST_IS_PROTOTYPE", object) and (is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object) or object.camp === null))
'
),
new GetCollection(
uriTemplate: self::CAMP_SUBRESOURCE_URI_TEMPLATE,
uriVariables: [
'campId' => new Link(
fromClass: Camp::class,
toProperty: 'camp',
fromClass: Camp::class,
security: 'is_granted("CAMP_COLLABORATOR", camp) or is_granted("CAMP_IS_PROTOTYPE", camp)'
),
],
Expand All @@ -70,8 +79,10 @@ class Checklist extends BaseEntity implements BelongsToCampInterface, CopyFromPr
*/
#[ApiProperty(example: '/camps/1a2b3c4d')]
#[Groups(['read', 'create'])]
#[Assert\Expression('!(this.isPrototype == true and this.camp != null)', 'This value should be null.')]
#[Assert\Expression('!(this.isPrototype == false and this.camp == null)', 'This value should not be null.')]
#[ORM\ManyToOne(targetEntity: Camp::class, inversedBy: 'checklists')]
#[ORM\JoinColumn(nullable: false, onDelete: 'cascade')]
#[ORM\JoinColumn(onDelete: 'cascade')]
public ?Camp $camp = null;

/**
Expand Down Expand Up @@ -101,6 +112,16 @@ class Checklist extends BaseEntity implements BelongsToCampInterface, CopyFromPr
#[ORM\Column(type: 'text')]
public string $name;

/**
* Whether this checklist is a template.
*/
#[Assert\Type('bool')]
#[Assert\DisableAutoMapping]
#[ApiProperty(example: true, writable: true)]
#[Groups(['read', 'create'])]
#[ORM\Column(type: 'boolean')]
public bool $isPrototype = false;

public function __construct() {
parent::__construct();
$this->checklistItems = new ArrayCollection();
Expand Down
48 changes: 35 additions & 13 deletions api/src/HttpCache/PurgeHttpCacheListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use App\Entity\BaseEntity;
use App\Entity\HasId;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
Expand Down Expand Up @@ -192,22 +194,22 @@ private function gatherResourceTagsForClass(string $resourceClass, object $entit
* (e.g. for updating period on a ScheduleEntry and the IRI changes from /periods/1/schedule_entries to /periods/2/schedule_entries)
*/
private function invalidateCollection(GetCollection $operation, object $entity, ?object $oldEntity = null): void {
$iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, $operation);
$iri = '';
$oldIri = '';

if (!$iri) {
return;
if ($this->canGenerateIri($operation, $entity)) {
$iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, $operation);
}

if (!$oldEntity) {
$this->cacheManager->invalidateTags([$iri]);

return;
if ($oldEntity && $this->canGenerateIri($operation, $oldEntity)) {
$oldIri = $this->iriConverter->getIriFromResource($oldEntity, UrlGeneratorInterface::ABS_PATH, $operation);
}

$oldIri = $this->iriConverter->getIriFromResource($oldEntity, UrlGeneratorInterface::ABS_PATH, $operation);
if ($oldIri && $iri !== $oldIri) {
$this->cacheManager->invalidateTags([$iri]);
$this->cacheManager->invalidateTags([$oldIri]);
if ($iri !== $oldIri) {
if ($iri) {
$this->cacheManager->invalidateTags([$iri]);
}
if ($oldIri) {
$this->cacheManager->invalidateTags([$oldIri]);
}
}
}

Expand Down Expand Up @@ -292,4 +294,24 @@ private function addTagForItem(mixed $value, ?string $property = null): void {
} catch (InvalidArgumentException|RuntimeException) {
}
}

private function canGenerateIri(GetCollection $operation, object $entity) {
if (is_iterable($operation->getUriVariables())) {
// UriVariable is Link, toProperty is set, fromClass is a entity
foreach ($operation->getUriVariables() as $uriVariable) {
if (is_a($uriVariable, Link::class)
&& is_string($uriVariable->getToProperty())
&& is_a($uriVariable->getFromClass(), HasId::class, true)
) {
// value of toProperty is NULL; Read of its ID will throw Exception
// -> invalid
if (null == $entity->{$uriVariable->getToProperty()}) {
return false;
}
}
}
}

return true;
}
}
2 changes: 1 addition & 1 deletion api/src/Repository/ChecklistItemRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInter
$checklistQry = $this->getEntityManager()->createQueryBuilder();
$checklistQry->select('c');
$checklistQry->from(Checklist::class, 'c');
$checklistQry->join(UserCamp::class, 'uc', Join::WITH, 'c.camp = uc.camp');
$checklistQry->join(UserCamp::class, 'uc', Join::WITH, 'c.camp = uc.camp OR c.isPrototype = true');
$checklistQry->where('uc.user = :current_user');

$rootAlias = $queryBuilder->getRootAliases()[0];
Expand Down
15 changes: 14 additions & 1 deletion api/src/Repository/ChecklistRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Checklist;
use App\Entity\User;
use App\Entity\UserCamp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
Expand All @@ -24,6 +25,18 @@ public function __construct(ManagerRegistry $registry) {

public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void {
$rootAlias = $queryBuilder->getRootAliases()[0];
$this->filterByCampCollaboration($queryBuilder, $user, "{$rootAlias}.camp");

$campsQry = $queryBuilder->getEntityManager()->createQueryBuilder();
$campsQry->select('identity(uc.camp)');
$campsQry->from(UserCamp::class, 'uc');
$campsQry->where('uc.user = :current_user');

$queryBuilder->andWhere(
$queryBuilder->expr()->orX(
"{$rootAlias}.isPrototype = true",
$queryBuilder->expr()->in("{$rootAlias}.camp", $campsQry->getDQL())
)
);
$queryBuilder->setParameter('current_user', $user);
}
}
25 changes: 25 additions & 0 deletions api/src/Security/Voter/ChecklistIsPrototypeVoter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Security\Voter;

use App\Entity\Checklist;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
* @extends Voter<string,Checklist>
*/
class ChecklistIsPrototypeVoter extends Voter {
public function __construct() {}

protected function supports($attribute, $subject): bool {
return 'CHECKLIST_IS_PROTOTYPE' === $attribute && $subject instanceof Checklist;
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool {
/** @var Checklist $checklist */
$checklist = $subject;

return $checklist->isPrototype;
}
}
56 changes: 55 additions & 1 deletion api/tests/Api/Checklists/CreateChecklistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,57 @@
* @internal
*/
class CreateChecklistTest extends ECampApiTestCase {
// Prototype-Checklist

public function testCreatePrototypeChecklistIsDeniedForAnonymousUser() {
static::createBasicClient()->request('POST', '/checklists', ['json' => $this->getExampleWritePayload(['isPrototype' => true, 'camp' => null])]);

$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains([
'code' => 401,
'message' => 'JWT Token not found',
]);
}

public function testCreatePrototypeChecklistIsDeniedForUser() {
static::createClientWithCredentials(['email' => static::$fixtures['user2member']->getEmail()])
->request('POST', '/checklists', ['json' => $this->getExampleWritePayload(['isPrototype' => true, 'camp' => null])])
;

$this->assertResponseStatusCodeSame(403);
$this->assertJsonContains([
'title' => 'An error occurred',
'detail' => 'Access Denied.',
]);
}

public function testCreatePrototypeChecklistIsAllowedForAdmin() {
static::createClientWithCredentials(['email' => static::$fixtures['admin']->getEmail()])
->request('POST', '/checklists', ['json' => $this->getExampleWritePayload(['isPrototype' => true, 'camp' => null])])
;

$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains($this->getExampleReadPayload(['isPrototype' => true]));
}

public function testCreatePrototypeChecklistWithCampIsDenied() {
static::createClientWithCredentials(['email' => static::$fixtures['admin']->getEmail()])
->request('POST', '/checklists', ['json' => $this->getExampleWritePayload(['isPrototype' => true, 'camp' => $this->getIriFor('campPrototype')])])
;

$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'camp',
'message' => 'This value should be null.',
],
],
]);
}

// Camp-Checklist

public function testCreateChecklistIsDeniedForAnonymousUser() {
static::createBasicClient()->request('POST', '/checklists', ['json' => $this->getExampleWritePayload()]);

Expand Down Expand Up @@ -291,6 +342,7 @@ public function getExampleWritePayload($attributes = [], $except = []) {
Checklist::class,
Post::class,
array_merge([
'isPrototype' => false,
'copyChecklistSource' => null,
'camp' => $this->getIriFor('camp1'),
], $attributes),
Expand All @@ -303,7 +355,9 @@ public function getExampleReadPayload($attributes = [], $except = []) {
return $this->getExamplePayload(
Checklist::class,
Get::class,
$attributes,
array_merge([
'isPrototype' => false,
], $attributes),
['camp', 'preferredContentTypes'],
$except
);
Expand Down
37 changes: 37 additions & 0 deletions api/tests/Api/Checklists/DeleteChecklistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@
* @internal
*/
class DeleteChecklistTest extends ECampApiTestCase {
// Prototype-Checklist

public function testDeletePrototypeChecklistIsDeniedForAnonymousUser() {
$checklist = static::getFixture('checklistPrototype');
static::createBasicClient()->request('DELETE', '/checklists/'.$checklist->getId());
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains([
'code' => 401,
'message' => 'JWT Token not found',
]);
}

public function testDeletePrototypeChecklistIsDeniedForUser() {
$checklist = static::getFixture('checklistPrototype');
static::createClientWithCredentials(['email' => static::$fixtures['user2member']->getEmail()])
->request('DELETE', '/checklists/'.$checklist->getId())
;

$this->assertResponseStatusCodeSame(403);
$this->assertJsonContains([
'title' => 'An error occurred',
'detail' => 'Access Denied.',
]);
}

public function testDeletePrototypeChecklistIsAllowedForAdmin() {
$checklist = static::getFixture('checklistPrototype');
static::createClientWithCredentials(['email' => static::$fixtures['admin']->getEmail()])
->request('DELETE', '/checklists/'.$checklist->getId())
;

$this->assertResponseStatusCodeSame(204);
$this->assertNull($this->getEntityManager()->getRepository(Checklist::class)->find($checklist->getId()));
}

// Camp-Checklist

public function testDeleteChecklistIsDeniedForAnonymousUser() {
$checklist = static::getFixture('checklist2WithNoItems');
static::createBasicClient()->request('DELETE', '/checklists/'.$checklist->getId());
Expand Down
3 changes: 2 additions & 1 deletion api/tests/Api/Checklists/ListChecklistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function testListChecklistsIsAllowedForLoggedInUserButFiltered() {
$response = static::createClientWithCredentials()->request('GET', '/checklists');
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains([
'totalItems' => 4,
'totalItems' => 5,
'_links' => [
'items' => [],
],
Expand All @@ -33,6 +33,7 @@ public function testListChecklistsIsAllowedForLoggedInUserButFiltered() {
],
]);
$this->assertEqualsCanonicalizing([
['href' => $this->getIriFor('checklistPrototype')],
['href' => $this->getIriFor('checklist1')],
['href' => $this->getIriFor('checklist2WithNoItems')],
['href' => $this->getIriFor('checklist1camp2')],
Expand Down
Loading

0 comments on commit 6886735

Please sign in to comment.