diff --git a/api/fixtures/checklists.yml b/api/fixtures/checklists.yml index e4515cedbd..466a38e012 100644 --- a/api/fixtures/checklists.yml +++ b/api/fixtures/checklists.yml @@ -14,3 +14,7 @@ App\Entity\Checklist: checklist1campPrototype: camp: '@campPrototype' name: 'J+S Ausbildungsziele' + checklistPrototype: + camp: null + isPrototype: true + name: 'J+S Ausbildungsziele' diff --git a/api/migrations/schema/Version20240912183023.php b/api/migrations/schema/Version20240912183023.php new file mode 100644 index 0000000000..ba32bf08f3 --- /dev/null +++ b/api/migrations/schema/Version20240912183023.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/api/src/Entity/Checklist.php b/api/src/Entity/Checklist.php index 12eebd1f8d..f277ebfae3 100644 --- a/api/src/Entity/Checklist.php +++ b/api/src/Entity/Checklist.php @@ -29,13 +29,20 @@ #[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()' @@ -43,14 +50,16 @@ 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)' ), ], @@ -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; /** @@ -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(); diff --git a/api/src/HttpCache/PurgeHttpCacheListener.php b/api/src/HttpCache/PurgeHttpCacheListener.php index b204d4a852..94793afd1f 100644 --- a/api/src/HttpCache/PurgeHttpCacheListener.php +++ b/api/src/HttpCache/PurgeHttpCacheListener.php @@ -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; @@ -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]); + } } } @@ -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; + } } diff --git a/api/src/Repository/ChecklistItemRepository.php b/api/src/Repository/ChecklistItemRepository.php index b7e45f2d10..1ea705272e 100644 --- a/api/src/Repository/ChecklistItemRepository.php +++ b/api/src/Repository/ChecklistItemRepository.php @@ -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]; diff --git a/api/src/Repository/ChecklistRepository.php b/api/src/Repository/ChecklistRepository.php index 4325bb3a5a..5d786131f8 100644 --- a/api/src/Repository/ChecklistRepository.php +++ b/api/src/Repository/ChecklistRepository.php @@ -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; @@ -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); } } diff --git a/api/src/Security/Voter/ChecklistIsPrototypeVoter.php b/api/src/Security/Voter/ChecklistIsPrototypeVoter.php new file mode 100644 index 0000000000..05441ada4a --- /dev/null +++ b/api/src/Security/Voter/ChecklistIsPrototypeVoter.php @@ -0,0 +1,25 @@ + + */ +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; + } +} diff --git a/api/tests/Api/Checklists/CreateChecklistTest.php b/api/tests/Api/Checklists/CreateChecklistTest.php index 12392aca9d..c34f93dc46 100644 --- a/api/tests/Api/Checklists/CreateChecklistTest.php +++ b/api/tests/Api/Checklists/CreateChecklistTest.php @@ -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()]); @@ -291,6 +342,7 @@ public function getExampleWritePayload($attributes = [], $except = []) { Checklist::class, Post::class, array_merge([ + 'isPrototype' => false, 'copyChecklistSource' => null, 'camp' => $this->getIriFor('camp1'), ], $attributes), @@ -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 ); diff --git a/api/tests/Api/Checklists/DeleteChecklistTest.php b/api/tests/Api/Checklists/DeleteChecklistTest.php index f2033769e3..3f52938b24 100644 --- a/api/tests/Api/Checklists/DeleteChecklistTest.php +++ b/api/tests/Api/Checklists/DeleteChecklistTest.php @@ -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()); diff --git a/api/tests/Api/Checklists/ListChecklistTest.php b/api/tests/Api/Checklists/ListChecklistTest.php index ef75a68b01..0450e5d44a 100644 --- a/api/tests/Api/Checklists/ListChecklistTest.php +++ b/api/tests/Api/Checklists/ListChecklistTest.php @@ -24,7 +24,7 @@ public function testListChecklistsIsAllowedForLoggedInUserButFiltered() { $response = static::createClientWithCredentials()->request('GET', '/checklists'); $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - 'totalItems' => 4, + 'totalItems' => 5, '_links' => [ 'items' => [], ], @@ -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')], diff --git a/api/tests/Api/Checklists/UpdateChecklistTest.php b/api/tests/Api/Checklists/UpdateChecklistTest.php index 3006e3a38e..5f681b1a6d 100644 --- a/api/tests/Api/Checklists/UpdateChecklistTest.php +++ b/api/tests/Api/Checklists/UpdateChecklistTest.php @@ -8,6 +8,50 @@ * @internal */ class UpdateChecklistTest extends ECampApiTestCase { + // Prototype-Checklist + + public function testPatchPrototypeChecklistIsDeniedForAnonymousUser() { + $checklist = static::getFixture('checklistPrototype'); + static::createBasicClient()->request('PATCH', '/checklists/'.$checklist->getId(), ['json' => [ + 'name' => 'ChecklistName', + ], 'headers' => ['Content-Type' => 'application/merge-patch+json']]); + $this->assertResponseStatusCodeSame(401); + $this->assertJsonContains([ + 'code' => 401, + 'message' => 'JWT Token not found', + ]); + } + + public function testPatchPrototypeChecklistIsDeniedForUser() { + $checklist = static::getFixture('checklistPrototype'); + static::createClientWithCredentials(['email' => static::$fixtures['user2member']->getEmail()]) + ->request('PATCH', '/checklists/'.$checklist->getId(), ['json' => [ + 'name' => 'ChecklistName', + ], 'headers' => ['Content-Type' => 'application/merge-patch+json']]) + ; + + $this->assertResponseStatusCodeSame(403); + $this->assertJsonContains([ + 'title' => 'An error occurred', + 'detail' => 'Access Denied.', + ]); + } + + public function testPatchPrototypeChecklistIsAllowedForAdmin() { + $checklist = static::getFixture('checklistPrototype'); + $response = static::createClientWithCredentials(['email' => static::$fixtures['admin']->getEmail()]) + ->request('PATCH', '/checklists/'.$checklist->getId(), ['json' => [ + 'name' => 'ChecklistName', + ], 'headers' => ['Content-Type' => 'application/merge-patch+json']]) + ; + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + 'name' => 'ChecklistName', + ]); + } + + // Camp-Checklist + public function testPatchChecklistIsDeniedForAnonymousUser() { $checklist = static::getFixture('checklist1'); static::createBasicClient()->request('PATCH', '/checklists/'.$checklist->getId(), ['json' => [ diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set checklists__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set checklists__1.json index feed0e6431..56845d37f9 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set checklists__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set checklists__1.json @@ -1,6 +1,20 @@ { "_embedded": { "items": [ + { + "_links": { + "camp": "escaped_value", + "checklistItems": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "id": "escaped_value", + "isPrototype": "escaped_value", + "name": "escaped_value" + }, { "_links": { "camp": { @@ -14,6 +28,7 @@ } }, "id": "escaped_value", + "isPrototype": "escaped_value", "name": "escaped_value" }, { @@ -29,6 +44,7 @@ } }, "id": "escaped_value", + "isPrototype": "escaped_value", "name": "escaped_value" }, { @@ -44,6 +60,7 @@ } }, "id": "escaped_value", + "isPrototype": "escaped_value", "name": "escaped_value" }, { @@ -59,6 +76,7 @@ } }, "id": "escaped_value", + "isPrototype": "escaped_value", "name": "escaped_value" } ] @@ -74,6 +92,9 @@ { "href": "escaped_value" }, + { + "href": "escaped_value" + }, { "href": "escaped_value" } diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetItemMatchesStructure with data set checklists__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetItemMatchesStructure with data set checklists__1.json index 328e6ab533..4885104de1 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetItemMatchesStructure with data set checklists__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetItemMatchesStructure with data set checklists__1.json @@ -11,5 +11,6 @@ } }, "id": "escaped_value", + "isPrototype": "escaped_value", "name": "escaped_value" } diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml index 599533456b..a9182f9886 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml @@ -7601,7 +7601,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string checklistItems: description: 'All ChecklistItems that belong to this Checklist.' example: 'https://example.com/' @@ -7614,13 +7616,16 @@ components: maxLength: 16 readOnly: true type: string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - checklistItems - name type: object @@ -7648,7 +7653,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string copyChecklistSource: description: 'Copy contents from this source checklist.' example: /checklists/1a2b3c4d @@ -7656,13 +7663,16 @@ components: type: - 'null' - string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - name type: object Checklist.jsonapi: @@ -7681,6 +7691,10 @@ components: maxLength: 16 readOnly: true type: string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' @@ -7698,7 +7712,6 @@ components: checklistItems: properties: { data: { items: { properties: { id: { format: iri-reference, type: string }, type: { type: string } }, type: object }, type: array } } required: - - camp - checklistItems type: object type: @@ -7739,7 +7752,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string checklistItems: description: 'All ChecklistItems that belong to this Checklist.' example: 'https://example.com/' @@ -7752,13 +7767,16 @@ components: maxLength: 16 readOnly: true type: string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - checklistItems - name type: object @@ -7781,7 +7799,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string copyChecklistSource: description: 'Copy contents from this source checklist.' example: /checklists/1a2b3c4d @@ -7789,13 +7809,16 @@ components: type: - 'null' - string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - name type: object Checklist.jsonld-read: @@ -7831,7 +7854,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string checklistItems: description: 'All ChecklistItems that belong to this Checklist.' example: 'https://example.com/' @@ -7844,13 +7869,16 @@ components: maxLength: 16 readOnly: true type: string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - checklistItems - name type: object @@ -7864,7 +7892,9 @@ components: description: 'The camp this checklist belongs to.' example: /camps/1a2b3c4d format: iri-reference - type: string + type: + - 'null' + - string copyChecklistSource: description: 'Copy contents from this source checklist.' example: /checklists/1a2b3c4d @@ -7872,13 +7902,16 @@ components: type: - 'null' - string + isPrototype: + description: 'Whether this checklist is a template.' + example: true + type: boolean name: description: 'The human readable name of the checklist.' example: 'PBS Ausbildungsziele' maxLength: 32 type: string required: - - camp - name type: object ChecklistItem-read: