diff --git a/api/fixtures/camps.yml b/api/fixtures/camps.yml index 403852be4d..01d6b73b14 100644 --- a/api/fixtures/camps.yml +++ b/api/fixtures/camps.yml @@ -54,6 +54,7 @@ App\Entity\Camp: addressCity: owner: '@admin' creator: '@admin' + isPublic: true isPrototype: true isShared: false sharedBy: null @@ -69,6 +70,7 @@ App\Entity\Camp: addressCity: owner: '@admin' creator: '@admin' + isPublic: true isPrototype: false isShared: true sharedBy: '@admin' diff --git a/api/migrations/schema/Version20250903154700.php b/api/migrations/schema/Version20250903154700.php new file mode 100644 index 0000000000..bb969d939d --- /dev/null +++ b/api/migrations/schema/Version20250903154700.php @@ -0,0 +1,56 @@ +addSql( + <<<'EOF' + CREATE OR REPLACE VIEW public.view_user_camps + AS + select cc.id::TEXT, cc.userid, cc.campid + from camp_collaboration cc + where cc.status = 'established' + EOF + ); + $this->addSql( + <<<'EOF' + CREATE OR REPLACE VIEW public.view_user_camps_with_public + AS + SELECT CONCAT(u.id, c.id) id, u.id userid, c.id campid + from camp c, "user" u + where c.isprototype = TRUE or c.isshared = TRUE + union all + select cc.id, cc.userid, cc.campid + from camp_collaboration cc + where cc.status = 'established' + EOF + ); + } + + public function down(Schema $schema): void { + $this->addSql('DROP VIEW IF EXISTS public.view_user_camps_with_public'); + $this->addSql( + <<<'EOF' + CREATE OR REPLACE VIEW public.view_user_camps + AS + SELECT CONCAT(u.id, c.id) id, u.id userid, c.id campid + from camp c, "user" u + where c.isprototype = TRUE + union all + select cc.id, cc.userid, cc.campid + from camp_collaboration cc + where cc.status = 'established' + EOF + ); + } +} diff --git a/api/migrations/schema/Version20251004093025.php b/api/migrations/schema/Version20251004093025.php new file mode 100644 index 0000000000..e1baefa753 --- /dev/null +++ b/api/migrations/schema/Version20251004093025.php @@ -0,0 +1,58 @@ +addSql('ALTER TABLE camp ADD isPublic BOOLEAN DEFAULT false NOT NULL'); + $this->addSql('UPDATE camp SET isPublic = (isShared OR isPrototype)'); + $this->addSql('ALTER TABLE camp ADD CONSTRAINT enforce_public_flag CHECK (isPublic = (isShared OR isPrototype))'); + $this->addSql('CREATE INDEX IDX_C1944230FADC24C7 ON camp (isPublic)'); + $this->addSql( + <<<'EOF' + CREATE OR REPLACE VIEW public.view_user_camps + AS + SELECT CONCAT(u.id, c.id) id, u.id userid, c.id campid + from camp c, "user" u + where c.ispublic = TRUE + union all + select cc.id, cc.userid, cc.campid + from camp_collaboration cc + where cc.status = 'established' + EOF + ); + } + + public function down(Schema $schema): void { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql( + <<<'EOF' + CREATE OR REPLACE VIEW public.view_user_camps + AS + SELECT CONCAT(u.id, c.id) id, u.id userid, c.id campid + from camp c, "user" u + where c.isprototype = TRUE or c.isshared = TRUE + union all + select cc.id, cc.userid, cc.campid + from camp_collaboration cc + where cc.status = 'established' + EOF + ); + $this->addSql('DROP INDEX IDX_C1944230FADC24C7'); + $this->addSql('ALTER TABLE camp DROP CONSTRAINT enforce_public_flag'); + $this->addSql('ALTER TABLE camp DROP isPublic'); + } +} diff --git a/api/src/Entity/Activity.php b/api/src/Entity/Activity.php index ac83ec6346..90fd6c5349 100644 --- a/api/src/Entity/Activity.php +++ b/api/src/Entity/Activity.php @@ -33,8 +33,7 @@ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, @@ -56,8 +55,7 @@ toProperty: 'camp', fromClass: Camp::class, security: 'is_granted("CAMP_COLLABORATOR", camp) or - is_granted("CAMP_IS_SHARED", camp) or - is_granted("CAMP_IS_PROTOTYPE", camp)' + is_granted("CAMP_IS_PUBLIC", camp)' ), ], normalizationContext: self::COLLECTION_NORMALIZATION_CONTEXT, @@ -275,7 +273,17 @@ public function removeScheduleEntry(ScheduleEntry $scheduleEntry): self { /** * @return ActivityResponsible[] */ - #[ApiProperty(readableLink: true)] + #[ApiProperty(readableLink: true, security: '!is_granted("CAMP_COLLABORATOR", object)')] + #[SerializedName('activityResponsibles')] + #[Groups(['Activity:ActivityResponsibles'])] + public function getRedactedEmbeddedActivityResponsibles(): array { + return []; + } + + /** + * @return ActivityResponsible[] + */ + #[ApiProperty(readableLink: true, security: 'is_granted("CAMP_COLLABORATOR", object)')] #[SerializedName('activityResponsibles')] #[Groups(['Activity:ActivityResponsibles'])] public function getEmbeddedActivityResponsibles(): array { diff --git a/api/src/Entity/ActivityProgressLabel.php b/api/src/Entity/ActivityProgressLabel.php index 76fdb943a8..fb01d4c5e2 100644 --- a/api/src/Entity/ActivityProgressLabel.php +++ b/api/src/Entity/ActivityProgressLabel.php @@ -30,8 +30,7 @@ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, @@ -53,8 +52,7 @@ toProperty: 'camp', fromClass: Camp::class, security: 'is_granted("CAMP_COLLABORATOR", camp) or - is_granted("CAMP_IS_SHARED", camp) or - is_granted("CAMP_IS_PROTOTYPE", camp)' + is_granted("CAMP_IS_PUBLIC", camp)' ), ], security: 'is_fully_authenticated()', diff --git a/api/src/Entity/ActivityResponsible.php b/api/src/Entity/ActivityResponsible.php index 5b30504d26..d939559501 100644 --- a/api/src/Entity/ActivityResponsible.php +++ b/api/src/Entity/ActivityResponsible.php @@ -23,7 +23,7 @@ #[ApiResource( operations: [ new Get( - security: 'is_granted("CAMP_COLLABORATOR", object) or is_granted("CAMP_IS_SHARED", object) or is_granted("CAMP_IS_PROTOTYPE", object)' + security: 'is_granted("CAMP_COLLABORATOR", object)' ), new Delete( security: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)' diff --git a/api/src/Entity/Camp.php b/api/src/Entity/Camp.php index bd604a195f..3bfb85e622 100644 --- a/api/src/Entity/Camp.php +++ b/api/src/Entity/Camp.php @@ -35,8 +35,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)', + is_granted("CAMP_IS_PUBLIC", object)', normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, ), new Patch( @@ -69,6 +68,7 @@ #[ORM\Entity(repositoryClass: CampRepository::class)] #[ORM\Index(columns: ['isPrototype'])] #[ORM\Index(columns: ['isShared'])] +#[ORM\Index(columns: ['isPublic'])] #[ORM\Index(columns: ['updateTime'])] // TODO unclear why this is necessary, but doctrine forgot about this index from BaseEntity... class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototypeInterface { public const ITEM_NORMALIZATION_CONTEXT = [ @@ -83,12 +83,21 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy public Collection $collaborations; /** - * UserCamp Collections - * Based von view_user_camps; lists all user who can see this camp. + * UserCamp Collection + * Based von view_user_camps; lists all user who can see this camp through campCollaborations. */ #[ORM\OneToMany(targetEntity: UserCamp::class, mappedBy: 'camp')] public Collection $userCamps; + /** + * UserCampWithPublic Collection + * Based von view_user_camps_with_public; lists all user who can see this camp, through + * campCollaborations or because the camps are prototypes or shared. + */ + #[ORM\OneToMany(targetEntity: UserCampWithPublic::class, mappedBy: 'camp')] + #[Assert\DisableAutoMapping] + public Collection $userCampsWithPublic; + /** * The time periods of the camp, there must be at least one. Periods in a camp may not overlap. * When creating a camp, the initial periods may be specified as nested payload, but updating, @@ -190,7 +199,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy public ?Camp $campPrototype = null; /** - * Whether the programme of this camp is publicly available to anyone (including + * Whether the programme of this camp is publicly available to anyone (except for * personal data such as camp collaborations, personal material lists, * responsibilities and comments). */ @@ -228,6 +237,14 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy #[ORM\Column(type: 'boolean')] public bool $isPrototype = false; + /** + * Automatically set to the value (isShared || isPrototype). Used for more efficient + * database filtering operations, since OR queries are very expensive to compute. + * This is only used in the database, and therefore not available on the API. + */ + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + public bool $isPublic = 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. @@ -484,13 +501,20 @@ public function getCampCollaborations(): array { return []; } + #[ApiProperty(writable: false, readableLink: true, security: '!is_granted("CAMP_COLLABORATOR", object)')] + #[SerializedName('campCollaborations')] + #[Groups('Camp:CampCollaborations')] + public function getRedactedEmbeddedCampCollaborations(): array { + return []; + } + /** * The people working on planning and carrying out the camp. Only collaborators have access * to the camp's contents. * * @return CampCollaboration[] */ - #[ApiProperty(writable: false, readableLink: true)] + #[ApiProperty(writable: false, readableLink: true, security: 'is_granted("CAMP_COLLABORATOR", object)')] #[SerializedName('campCollaborations')] #[Groups('Camp:CampCollaborations')] public function getEmbeddedCampCollaborations(): array { diff --git a/api/src/Entity/CampCollaboration.php b/api/src/Entity/CampCollaboration.php index 6d9b502eec..de220dfa59 100644 --- a/api/src/Entity/CampCollaboration.php +++ b/api/src/Entity/CampCollaboration.php @@ -35,7 +35,7 @@ operations: [ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, - security: 'is_granted("CAMP_COLLABORATOR", object) or is_granted("CAMP_IS_SHARED", object) or is_granted("CAMP_IS_PROTOTYPE", object)' + security: 'is_granted("CAMP_COLLABORATOR", object)' ), new Patch( processor: CampCollaborationUpdateProcessor::class, @@ -68,8 +68,7 @@ toProperty: 'camp', fromClass: Camp::class, security: 'is_granted("CAMP_COLLABORATOR", camp) or - is_granted("CAMP_IS_SHARED", camp) or - is_granted("CAMP_IS_PROTOTYPE", camp)' + is_granted("CAMP_IS_PUBLIC", camp)' ), ], security: 'is_fully_authenticated()', diff --git a/api/src/Entity/Category.php b/api/src/Entity/Category.php index 771b189a72..be7c0ed353 100644 --- a/api/src/Entity/Category.php +++ b/api/src/Entity/Category.php @@ -36,8 +36,7 @@ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( denormalizationContext: ['groups' => ['write', 'update']], @@ -66,8 +65,7 @@ fromClass: Camp::class, toProperty: 'camp', security: 'is_granted("CAMP_COLLABORATOR", camp) or - is_granted("CAMP_IS_SHARED", camp) or - is_granted("CAMP_IS_PROTOTYPE", camp)' + is_granted("CAMP_IS_PUBLIC", camp)' ), ], extraProperties: [ diff --git a/api/src/Entity/Checklist.php b/api/src/Entity/Checklist.php index 3dcb87c200..d72ffdc545 100644 --- a/api/src/Entity/Checklist.php +++ b/api/src/Entity/Checklist.php @@ -30,8 +30,7 @@ operations: [ new Get( security: 'is_granted("CHECKLIST_IS_PROTOTYPE", object) or - is_granted("CAMP_IS_PROTOTYPE", object) or - is_granted("CAMP_IS_SHARED", object) or + is_granted("CAMP_IS_PUBLIC", object) or is_granted("CAMP_COLLABORATOR", object) ' ), @@ -64,8 +63,7 @@ toProperty: 'camp', fromClass: Camp::class, security: 'is_granted("CAMP_COLLABORATOR", camp) or - is_granted("CAMP_IS_SHARED", camp) or - is_granted("CAMP_IS_PROTOTYPE", camp)' + is_granted("CAMP_IS_PUBLIC", camp)' ), ], extraProperties: [ diff --git a/api/src/Entity/ChecklistItem.php b/api/src/Entity/ChecklistItem.php index 251c6dce5c..076278f164 100644 --- a/api/src/Entity/ChecklistItem.php +++ b/api/src/Entity/ChecklistItem.php @@ -33,8 +33,7 @@ operations: [ new Get( security: 'is_granted("CHECKLIST_IS_PROTOTYPE", object) or - is_granted("CAMP_IS_PROTOTYPE", object) or - is_granted("CAMP_IS_SHARED", object) or + is_granted("CAMP_IS_PUBLIC", object) or is_granted("CAMP_COLLABORATOR", object) ' ), @@ -65,8 +64,7 @@ fromClass: Checklist::class, toProperty: 'checklist', security: 'is_granted("CHECKLIST_IS_PROTOTYPE", checklist) or - is_granted("CAMP_IS_PROTOTYPE", checklist) or - is_granted("CAMP_IS_SHARED", checklist) or + is_granted("CAMP_IS_PUBLIC", checklist) or is_granted("CAMP_COLLABORATOR", checklist)' ), ], diff --git a/api/src/Entity/Comment.php b/api/src/Entity/Comment.php index 2e54e037d5..75da8a09b1 100644 --- a/api/src/Entity/Comment.php +++ b/api/src/Entity/Comment.php @@ -27,8 +27,6 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object) or object.author === user', ), new Delete( @@ -49,8 +47,7 @@ toProperty: 'activity', fromClass: Activity::class, security: 'is_granted("CAMP_COLLABORATOR", activity) or - is_granted("CAMP_IS_SHARED", activity) or - is_granted("CAMP_IS_PROTOTYPE", activity)', + is_granted("CAMP_IS_PUBLIC", activity)', ), ], security: 'is_fully_authenticated()', diff --git a/api/src/Entity/ContentNode/ChecklistNode.php b/api/src/Entity/ContentNode/ChecklistNode.php index d46a4063e2..615291e6fd 100644 --- a/api/src/Entity/ContentNode/ChecklistNode.php +++ b/api/src/Entity/ContentNode/ChecklistNode.php @@ -24,8 +24,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: ChecklistNodePersistProcessor::class, diff --git a/api/src/Entity/ContentNode/ColumnLayout.php b/api/src/Entity/ContentNode/ColumnLayout.php index 88cce2b3eb..8882c33202 100644 --- a/api/src/Entity/ContentNode/ColumnLayout.php +++ b/api/src/Entity/ContentNode/ColumnLayout.php @@ -26,8 +26,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: ContentNodePersistProcessor::class, diff --git a/api/src/Entity/ContentNode/MaterialNode.php b/api/src/Entity/ContentNode/MaterialNode.php index 03252d1b5c..36c987633b 100644 --- a/api/src/Entity/ContentNode/MaterialNode.php +++ b/api/src/Entity/ContentNode/MaterialNode.php @@ -24,8 +24,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: ContentNodePersistProcessor::class, diff --git a/api/src/Entity/ContentNode/MultiSelect.php b/api/src/Entity/ContentNode/MultiSelect.php index 60cf19d4bc..c027af4160 100644 --- a/api/src/Entity/ContentNode/MultiSelect.php +++ b/api/src/Entity/ContentNode/MultiSelect.php @@ -22,8 +22,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: ContentNodePersistProcessor::class, diff --git a/api/src/Entity/ContentNode/ResponsiveLayout.php b/api/src/Entity/ContentNode/ResponsiveLayout.php index 89f2e8988f..179c3fa598 100644 --- a/api/src/Entity/ContentNode/ResponsiveLayout.php +++ b/api/src/Entity/ContentNode/ResponsiveLayout.php @@ -25,8 +25,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: ContentNodePersistProcessor::class, diff --git a/api/src/Entity/ContentNode/SingleText.php b/api/src/Entity/ContentNode/SingleText.php index 674fa89cfe..200d70127f 100644 --- a/api/src/Entity/ContentNode/SingleText.php +++ b/api/src/Entity/ContentNode/SingleText.php @@ -21,8 +21,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: SingleTextPersistProcessor::class, diff --git a/api/src/Entity/ContentNode/Storyboard.php b/api/src/Entity/ContentNode/Storyboard.php index e577a08912..4e0afea877 100644 --- a/api/src/Entity/ContentNode/Storyboard.php +++ b/api/src/Entity/ContentNode/Storyboard.php @@ -21,8 +21,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( processor: StoryboardPersistProcessor::class, diff --git a/api/src/Entity/Day.php b/api/src/Entity/Day.php index 7877604803..e47422cee8 100644 --- a/api/src/Entity/Day.php +++ b/api/src/Entity/Day.php @@ -29,8 +29,7 @@ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new GetCollection( normalizationContext: self::COLLECTION_NORMALIZATION_CONTEXT, @@ -43,8 +42,7 @@ toProperty: 'period', fromClass: Period::class, security: 'is_granted("CAMP_COLLABORATOR", period) or - is_granted("CAMP_IS_SHARED", period) or - is_granted("CAMP_IS_PROTOTYPE", period)' + is_granted("CAMP_IS_PUBLIC", period)' ), ], normalizationContext: self::COLLECTION_NORMALIZATION_CONTEXT, @@ -180,16 +178,26 @@ public function getEnd(): ?\DateTime { } } - /** - * DayResponsible. - */ + #[ApiProperty( + readableLink: true, + uriTemplate: DayResponsible::DAY_SUBRESOURCE_URI_TEMPLATE, + security: '!is_granted("CAMP_COLLABORATOR", object)' + )] + #[SerializedName('dayResponsibles')] + #[Groups(['Day:DayResponsibles'])] + public function getRedactedEmbeddedDayResponsibles(): array { + return []; + } /** + * People who have a special responsibility on this day. + * * @return DayResponsible[] */ #[ApiProperty( readableLink: true, uriTemplate: DayResponsible::DAY_SUBRESOURCE_URI_TEMPLATE, + security: 'is_granted("CAMP_COLLABORATOR", object)' )] #[SerializedName('dayResponsibles')] #[Groups(['Day:DayResponsibles'])] diff --git a/api/src/Entity/DayResponsible.php b/api/src/Entity/DayResponsible.php index 8b1d024d8f..e3ef512683 100644 --- a/api/src/Entity/DayResponsible.php +++ b/api/src/Entity/DayResponsible.php @@ -24,9 +24,7 @@ #[ApiResource( operations: [ new Get( - security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + security: 'is_granted("CAMP_COLLABORATOR", object)' ), new Delete( security: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)' @@ -41,12 +39,8 @@ toProperty: 'day', fromClass: Day::class, security: 'is_granted("CAMP_COLLABORATOR", day) or - is_granted("CAMP_IS_SHARED", day) or - is_granted("CAMP_IS_PROTOTYPE", day)' + is_granted("CAMP_IS_PUBLIC", day)' ), - ], - extraProperties: [ - 'filter_by_current_user' => false, ] ), new Post( diff --git a/api/src/Entity/MaterialItem.php b/api/src/Entity/MaterialItem.php index dc0e4d161a..b51ba889b2 100644 --- a/api/src/Entity/MaterialItem.php +++ b/api/src/Entity/MaterialItem.php @@ -24,6 +24,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; /** @@ -33,8 +34,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( validationContext: ['groups' => MaterialItemUpdateGroupSequence::class], @@ -71,14 +71,6 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface, CopyFro #[ORM\JoinColumn(nullable: false, onDelete: 'cascade')] public ?Camp $camp = null; - /** - * The list to which this item belongs. Lists are used to keep track of who is - * responsible to prepare and bring the item to the camp. - */ - #[Assert\NotNull] - #[AssertBelongsToSameCamp] - #[ApiProperty(example: '/material_lists/1a2b3c4d')] - #[Groups(['read', 'write'])] #[ORM\ManyToOne(targetEntity: MaterialList::class, inversedBy: 'materialItems')] #[ORM\JoinColumn(nullable: true, onDelete: 'cascade')] public ?MaterialList $materialList = null; @@ -157,6 +149,36 @@ public function __construct() { $this->periodMaterialItems = new ArrayCollection(); } + /** + * The list to which this item belongs. Lists are used to keep track of who is + * responsible to prepare and bring the item to the camp. + */ + #[Assert\NotNull] + #[AssertBelongsToSameCamp] + #[ApiProperty(example: '/material_lists/1a2b3c4d', security: 'is_granted("CAMP_COLLABORATOR", object)')] + #[Groups(['read', 'write'])] + public function getMaterialList(): ?MaterialList { + return $this->materialList; + } + + /** + * The list to which this item belongs. Lists are used to keep track of who is + * responsible to prepare and bring the item to the camp. + */ + #[Assert\NotNull] + #[AssertBelongsToSameCamp] + #[ApiProperty(example: '/material_lists/1a2b3c4d', security: '!is_granted("CAMP_COLLABORATOR", object)')] + #[Groups(['read', 'write'])] + #[SerializedName('materialList')] + public function getPublicMaterialList(): ?MaterialList { + // When accessing a shared or prototype camp, hide personal material lists + if (null !== $this->materialList->campCollaboration) { + return null; + } + + return $this->materialList; + } + public function getCamp(): ?Camp { return $this->camp ?? $this->period->camp ?? $this->materialNode?->getCamp(); } diff --git a/api/src/Entity/MaterialList.php b/api/src/Entity/MaterialList.php index 2d6982c15e..d60addce14 100644 --- a/api/src/Entity/MaterialList.php +++ b/api/src/Entity/MaterialList.php @@ -29,8 +29,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + (object.campCollaboration === null and is_granted("CAMP_IS_PUBLIC", object))' ), new Patch( security: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)' diff --git a/api/src/Entity/Period.php b/api/src/Entity/Period.php index 3dc019376a..86d2f22a83 100644 --- a/api/src/Entity/Period.php +++ b/api/src/Entity/Period.php @@ -36,8 +36,7 @@ operations: [ new Get( security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)', + is_granted("CAMP_IS_PUBLIC", object)', normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, ), new Patch( diff --git a/api/src/Entity/ScheduleEntry.php b/api/src/Entity/ScheduleEntry.php index ad2e7ec95d..d90d118f52 100644 --- a/api/src/Entity/ScheduleEntry.php +++ b/api/src/Entity/ScheduleEntry.php @@ -32,8 +32,7 @@ new Get( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, security: 'is_granted("CAMP_COLLABORATOR", object) or - is_granted("CAMP_IS_SHARED", object) or - is_granted("CAMP_IS_PROTOTYPE", object)' + is_granted("CAMP_IS_PUBLIC", object)' ), new Patch( normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, @@ -54,8 +53,7 @@ toProperty: 'period', fromClass: Period::class, security: 'is_granted("CAMP_COLLABORATOR", period) or - is_granted("CAMP_IS_SHARED", period) or - is_granted("CAMP_IS_PROTOTYPE", period)' + is_granted("CAMP_IS_PUBLIC", period)' ), ], security: 'is_fully_authenticated()', diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 9356dc1919..9ff8fb45a9 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -88,12 +88,20 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse public Collection $collaborations; /** - * UserCamp Collections - * Based von view_user_camps; lists all camps a user can see. + * UserCamp Collection + * Based von view_user_camps; lists all camps a user can see through their campCollaborations. */ #[ORM\OneToMany(targetEntity: UserCamp::class, mappedBy: 'user')] public Collection $userCamps; + /** + * UserCampWithPublic Collection + * Based von view_user_camps_with_public; lists all camps a user can see, through their + * campCollaborations or because the camp is a prototype or shared. + */ + #[ORM\OneToMany(targetEntity: UserCampWithPublic::class, mappedBy: 'user')] + public Collection $userCampsWithPublic; + /** * The state of this user. */ diff --git a/api/src/Entity/UserCamp.php b/api/src/Entity/UserCamp.php index 07eb2598b1..0b2b49e87b 100644 --- a/api/src/Entity/UserCamp.php +++ b/api/src/Entity/UserCamp.php @@ -6,7 +6,7 @@ /** * view_user_camps - * List all visible camps for each user. + * List all visible camps for each user through their camp collaborations. */ #[ORM\Entity(readOnly: true)] #[ORM\Table(name: 'view_user_camps')] diff --git a/api/src/Entity/UserCampWithPublic.php b/api/src/Entity/UserCampWithPublic.php new file mode 100644 index 0000000000..95cef7e791 --- /dev/null +++ b/api/src/Entity/UserCampWithPublic.php @@ -0,0 +1,24 @@ +getRootAliases()[0]; - $this->filterByCampCollaboration($queryBuilder, $user, "{$rootAlias}.camp"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$rootAlias}.camp"); } } diff --git a/api/src/Repository/ActivityRepository.php b/api/src/Repository/ActivityRepository.php index 5485576866..84fac771c5 100644 --- a/api/src/Repository/ActivityRepository.php +++ b/api/src/Repository/ActivityRepository.php @@ -24,6 +24,6 @@ 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"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$rootAlias}.camp"); } } diff --git a/api/src/Repository/ActivityResponsibleRepository.php b/api/src/Repository/ActivityResponsibleRepository.php index 44a4cf1516..253aa03d78 100644 --- a/api/src/Repository/ActivityResponsibleRepository.php +++ b/api/src/Repository/ActivityResponsibleRepository.php @@ -25,6 +25,7 @@ public function __construct(ManagerRegistry $registry) { public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void { $activity = QueryBuilderHelper::findOrAddInnerRootJoinAlias($queryBuilder, $queryNameGenerator, 'activity'); - $this->filterByCampCollaboration($queryBuilder, $user, "{$activity}.camp"); + $queryBuilder->innerJoin("{$activity}.camp", 'camp'); + $this->filterByCampCollaboration($queryBuilder, $user); } } diff --git a/api/src/Repository/CampCollaborationRepository.php b/api/src/Repository/CampCollaborationRepository.php index c31f2572ed..f7136c5b2c 100644 --- a/api/src/Repository/CampCollaborationRepository.php +++ b/api/src/Repository/CampCollaborationRepository.php @@ -51,6 +51,7 @@ public function findAllByPersonallyInvitedUser(User $user): array { public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void { $rootAlias = $queryBuilder->getRootAliases()[0]; - $this->filterByCampCollaboration($queryBuilder, $user, "{$rootAlias}.camp"); + $queryBuilder->innerJoin("{$rootAlias}.camp", 'camp'); + $this->filterByCampCollaboration($queryBuilder, $user); } } diff --git a/api/src/Repository/CampRepository.php b/api/src/Repository/CampRepository.php index ac44e07692..82cb5b9c62 100644 --- a/api/src/Repository/CampRepository.php +++ b/api/src/Repository/CampRepository.php @@ -25,6 +25,6 @@ public function __construct(ManagerRegistry $registry) { public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void { /** @var string $rootAlias */ $rootAlias = $queryBuilder->getRootAliases()[0]; - $this->filterByCampCollaboration($queryBuilder, $user, $rootAlias); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, $rootAlias); } } diff --git a/api/src/Repository/CategoryRepository.php b/api/src/Repository/CategoryRepository.php index bbcf86e520..e201198da8 100644 --- a/api/src/Repository/CategoryRepository.php +++ b/api/src/Repository/CategoryRepository.php @@ -24,6 +24,6 @@ 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"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$rootAlias}.camp"); } } diff --git a/api/src/Repository/DayRepository.php b/api/src/Repository/DayRepository.php index b364fd7397..165afe018b 100644 --- a/api/src/Repository/DayRepository.php +++ b/api/src/Repository/DayRepository.php @@ -25,6 +25,6 @@ public function __construct(ManagerRegistry $registry) { public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void { $period = QueryBuilderHelper::findOrAddInnerRootJoinAlias($queryBuilder, $queryNameGenerator, 'period'); - $this->filterByCampCollaboration($queryBuilder, $user, "{$period}.camp"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$period}.camp"); } } diff --git a/api/src/Repository/DayResponsibleRepository.php b/api/src/Repository/DayResponsibleRepository.php index 905411bb8c..dfb34813a1 100644 --- a/api/src/Repository/DayResponsibleRepository.php +++ b/api/src/Repository/DayResponsibleRepository.php @@ -27,6 +27,7 @@ public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInter $day = QueryBuilderHelper::findOrAddInnerRootJoinAlias($queryBuilder, $queryNameGenerator, 'day'); $period = QueryBuilderHelper::findOrAddInnerJoinAlias($queryBuilder, $queryNameGenerator, $day, 'period'); - $this->filterByCampCollaboration($queryBuilder, $user, "{$period}.camp"); + $queryBuilder->innerJoin("{$period}.camp", 'camp'); + $this->filterByCampCollaboration($queryBuilder, $user); } } diff --git a/api/src/Repository/FiltersByCampCollaboration.php b/api/src/Repository/FiltersByCampCollaboration.php index 1e39500199..0729df85c6 100644 --- a/api/src/Repository/FiltersByCampCollaboration.php +++ b/api/src/Repository/FiltersByCampCollaboration.php @@ -4,12 +4,29 @@ use App\Entity\User; use App\Entity\UserCamp; +use App\Entity\UserCampWithPublic; use Doctrine\ORM\QueryBuilder; trait FiltersByCampCollaboration { /** * Applies a filter that checks for an active campCollaboration of the passed user, or that - * the camp is a prototype. + * the camp is a prototype or shared. + * Assumes the queryBuilder already knows how to get to the corresponding camp. You can pass + * the alias of the camp as the third argument if it's anything other than "camp". + */ + protected function filterByCampCollaborationOrPublic(QueryBuilder $queryBuilder, User $user, string $campAlias = 'camp'): void { + $campsQry = $queryBuilder->getEntityManager()->createQueryBuilder(); + $campsQry->select('identity(ucwp.camp)'); + $campsQry->from(UserCampWithPublic::class, 'ucwp'); + $campsQry->where('ucwp.user = :current_user'); + + $queryBuilder->andWhere($queryBuilder->expr()->in($campAlias, $campsQry->getDQL())); + $queryBuilder->setParameter('current_user', $user); + } + + /** + * Applies a filter that checks for an active campCollaboration of the passed user. Camps which + * are prototypes or marked as shared do not count here. * Assumes the queryBuilder already knows how to get to the corresponding camp. You can pass * the alias of the camp as the third argument if it's anything other than "camp". */ diff --git a/api/src/Repository/FiltersByContentNode.php b/api/src/Repository/FiltersByContentNode.php index b799e28cdb..7a08a499b0 100644 --- a/api/src/Repository/FiltersByContentNode.php +++ b/api/src/Repository/FiltersByContentNode.php @@ -3,9 +3,8 @@ namespace App\Repository; use App\Entity\CampRootContentNode; -use App\Entity\ContentNode; use App\Entity\User; -use App\Entity\UserCamp; +use App\Entity\UserCampWithPublic; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -24,8 +23,8 @@ protected function filterByContentNode(QueryBuilder $queryBuilder, User $user, s $rootQry = $queryBuilder->getEntityManager()->createQueryBuilder(); $rootQry->select('identity(r.rootContentNode)'); $rootQry->from(CampRootContentNode::class, 'r'); - $rootQry->join(UserCamp::class, 'uc', Join::WITH, 'r.camp = uc.camp'); - $rootQry->where('uc.user = :current_user'); + $rootQry->join(UserCampWithPublic::class, 'ucwp', Join::WITH, 'r.camp = ucwp.camp'); + $rootQry->where('ucwp.user = :current_user'); $queryBuilder->andWhere($queryBuilder->expr()->in("{$contentNodeAlias}.root", $rootQry->getDQL())); $queryBuilder->setParameter('current_user', $user); diff --git a/api/src/Repository/MaterialItemRepository.php b/api/src/Repository/MaterialItemRepository.php index ae6a4c514f..ac20be06ec 100644 --- a/api/src/Repository/MaterialItemRepository.php +++ b/api/src/Repository/MaterialItemRepository.php @@ -24,6 +24,6 @@ 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"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$rootAlias}.camp"); } } diff --git a/api/src/Repository/MaterialListRepository.php b/api/src/Repository/MaterialListRepository.php index 414672297c..55ae773482 100644 --- a/api/src/Repository/MaterialListRepository.php +++ b/api/src/Repository/MaterialListRepository.php @@ -3,8 +3,10 @@ namespace App\Repository; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use App\Entity\Camp; use App\Entity\MaterialList; use App\Entity\User; +use App\Entity\UserCamp; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -16,14 +18,32 @@ * @method MaterialList[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class MaterialListRepository extends ServiceEntityRepository implements CanFilterByUserInterface { - use FiltersByCampCollaboration; - public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MaterialList::class); } 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'); + + $publicCampsQry = $queryBuilder->getEntityManager()->createQueryBuilder(); + $publicCampsQry->select('c'); + $publicCampsQry->from(Camp::class, 'c'); + $publicCampsQry->where($queryBuilder->expr()->orX('c.isPrototype = true', 'c.isShared = true')); + + $queryBuilder->andWhere( + $queryBuilder->expr()->orX( + $queryBuilder->expr()->andX( + "{$rootAlias}.campCollaboration IS NULL", + $queryBuilder->expr()->in("{$rootAlias}.camp", $publicCampsQry->getDQL()) + ), + $queryBuilder->expr()->in("{$rootAlias}.camp", $campsQry->getDQL()) + ) + ); + $queryBuilder->setParameter('current_user', $user); } } diff --git a/api/src/Repository/PeriodRepository.php b/api/src/Repository/PeriodRepository.php index da3738f739..fd021bf79e 100644 --- a/api/src/Repository/PeriodRepository.php +++ b/api/src/Repository/PeriodRepository.php @@ -24,6 +24,6 @@ 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"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$rootAlias}.camp"); } } diff --git a/api/src/Repository/ScheduleEntryRepository.php b/api/src/Repository/ScheduleEntryRepository.php index f9cc7f217f..b63e62390f 100644 --- a/api/src/Repository/ScheduleEntryRepository.php +++ b/api/src/Repository/ScheduleEntryRepository.php @@ -37,6 +37,6 @@ public function createQueryBuilder($alias, $indexBy = null): QueryBuilder { public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void { $activity = QueryBuilderHelper::findOrAddInnerRootJoinAlias($queryBuilder, $queryNameGenerator, 'activity'); - $this->filterByCampCollaboration($queryBuilder, $user, "{$activity}.camp"); + $this->filterByCampCollaborationOrPublic($queryBuilder, $user, "{$activity}.camp"); } } diff --git a/api/src/Security/Voter/CampIsPrototypeVoter.php b/api/src/Security/Voter/CampIsPrototypeVoter.php deleted file mode 100644 index c2ea8dcdcb..0000000000 --- a/api/src/Security/Voter/CampIsPrototypeVoter.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ -class CampIsPrototypeVoter extends Voter { - use GetCampFromContentNodeTrait; - - public function __construct( - private readonly EntityManagerInterface $em, - private readonly ResponseTagger $responseTagger - ) {} - - protected function supports($attribute, $subject): bool { - return 'CAMP_IS_PROTOTYPE' === $attribute - && ($subject instanceof BelongsToCampInterface || $subject instanceof BelongsToContentNodeTreeInterface); - } - - protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { - $camp = $this->getCampFromInterface($subject, $this->em); - - if (null === $camp) { - return false; - } - - if ($camp->isPrototype) { - $this->responseTagger->addTags([$camp->getId()]); - - return true; - } - - return false; - } -} diff --git a/api/src/Security/Voter/CampIsSharedVoter.php b/api/src/Security/Voter/CampIsPublicVoter.php similarity index 91% rename from api/src/Security/Voter/CampIsSharedVoter.php rename to api/src/Security/Voter/CampIsPublicVoter.php index 9738f9011f..3a0c06add9 100644 --- a/api/src/Security/Voter/CampIsSharedVoter.php +++ b/api/src/Security/Voter/CampIsPublicVoter.php @@ -13,7 +13,7 @@ /** * @extends Voter */ -class CampIsSharedVoter extends Voter { +class CampIsPublicVoter extends Voter { use GetCampFromContentNodeTrait; public function __construct( @@ -22,7 +22,7 @@ public function __construct( ) {} protected function supports($attribute, $subject): bool { - return 'CAMP_IS_SHARED' === $attribute + return 'CAMP_IS_PUBLIC' === $attribute && ($subject instanceof BelongsToCampInterface || $subject instanceof BelongsToContentNodeTreeInterface); } @@ -33,7 +33,7 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter return false; } - if ($camp->isShared) { + if ($camp->isPublic) { $this->responseTagger->addTags([$camp->getId()]); return true; diff --git a/api/src/State/CampCreateProcessor.php b/api/src/State/CampCreateProcessor.php index 3a72ec822b..10dd6462e4 100644 --- a/api/src/State/CampCreateProcessor.php +++ b/api/src/State/CampCreateProcessor.php @@ -34,6 +34,7 @@ public function onBefore($data, Operation $operation, array $uriVariables = [], $user = $this->security->getUser(); $data->creator = $user; $data->owner = $user; + $data->isPublic = $data->isShared || $data->isPrototype; // copy from prototype, if given if (null !== $data->campPrototype) { diff --git a/api/src/State/CampUpdateProcessor.php b/api/src/State/CampUpdateProcessor.php index e13921db33..c1f171df67 100644 --- a/api/src/State/CampUpdateProcessor.php +++ b/api/src/State/CampUpdateProcessor.php @@ -32,6 +32,8 @@ public function __construct( } public function onBeforeSharingStatusChange(Camp $data): Camp { + $data->isPublic = $data->isShared || $data->isPrototype; + if ($data->isShared) { $data->sharedSince = new \DateTime('now', new \DateTimeZone('UTC')); diff --git a/api/tests/Api/MaterialItems/ListMaterialItemsTest.php b/api/tests/Api/MaterialItems/ListMaterialItemsTest.php index 9e86bb326b..1cc0d2ec3d 100644 --- a/api/tests/Api/MaterialItems/ListMaterialItemsTest.php +++ b/api/tests/Api/MaterialItems/ListMaterialItemsTest.php @@ -189,7 +189,7 @@ public function testListMaterialItemsFilteredByPeriodIsDeniedForUnrelatedUser() ]); } - public function testListMaterialItemsFilteredByPeriodIsDeniedForInactiveCollaborator() { + public function testListMaterialItemsFilteredByPeriodIsDeniedForInvitedCollaborator() { $period = static::getFixture('period1'); static::createClientWithCredentials(['email' => static::$fixtures['user5inactive']->getEmail()]) ->request('GET', '/material_items?period=%2Fperiods%2F'.$period->getId()) diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set activities__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set activities__1.json index 0c06ddbfc2..db9ad1744a 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set activities__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set activities__1.json @@ -366,6 +366,104 @@ "location": "escaped_value", "title": "escaped_value" }, + { + "_embedded": { + "activityResponsibles": [ + { + "_links": { + "activity": { + "href": "escaped_value" + }, + "campCollaboration": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "id": "escaped_value" + } + ], + "progressLabel": "escaped_value", + "scheduleEntries": [ + { + "_links": { + "activity": { + "href": "escaped_value" + }, + "day": { + "href": "escaped_value" + }, + "period": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "dayNumber": "escaped_value", + "end": "escaped_value", + "id": "escaped_value", + "left": "escaped_value", + "number": "escaped_value", + "scheduleEntryNumber": "escaped_value", + "start": "escaped_value", + "width": "escaped_value" + }, + { + "_links": { + "activity": { + "href": "escaped_value" + }, + "day": { + "href": "escaped_value" + }, + "period": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "dayNumber": "escaped_value", + "end": "escaped_value", + "id": "escaped_value", + "left": "escaped_value", + "number": "escaped_value", + "scheduleEntryNumber": "escaped_value", + "start": "escaped_value", + "width": "escaped_value" + } + ] + }, + "_links": { + "activityResponsibles": { + "href": "escaped_value" + }, + "camp": { + "href": "escaped_value" + }, + "category": { + "href": "escaped_value" + }, + "contentNodes": { + "href": "escaped_value" + }, + "progressLabel": "escaped_value", + "rootContentNode": { + "href": "escaped_value" + }, + "scheduleEntries": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "id": "escaped_value", + "location": "escaped_value", + "title": "escaped_value" + }, { "_embedded": { "activityResponsibles": [ diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set comments__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set comments__1.json index b8955adcfb..3fd9f5f391 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set comments__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testGetCollectionMatchesStructure with data set comments__1.json @@ -100,6 +100,44 @@ "id": "escaped_value", "orphanDescription": "escaped_value", "textHtml": "escaped_value" + }, + { + "_links": { + "activity": { + "href": "escaped_value" + }, + "author": { + "href": "escaped_value" + }, + "camp": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "id": "escaped_value", + "orphanDescription": "escaped_value", + "textHtml": "escaped_value" + }, + { + "_links": { + "activity": { + "href": "escaped_value" + }, + "author": { + "href": "escaped_value" + }, + "camp": { + "href": "escaped_value" + }, + "self": { + "href": "escaped_value" + } + }, + "id": "escaped_value", + "orphanDescription": "escaped_value", + "textHtml": "escaped_value" } ] }, diff --git a/api/tests/Security/Voter/CampIsPrototypeVoterTest.php b/api/tests/Security/Voter/CampIsPrototypeVoterTest.php deleted file mode 100644 index a8a57c64f1..0000000000 --- a/api/tests/Security/Voter/CampIsPrototypeVoterTest.php +++ /dev/null @@ -1,147 +0,0 @@ -token = $this->createMock(TokenInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); - $this->responseTagger = $this->createMock(ResponseTagger::class); - $this->voter = new CampIsPrototypeVoter($this->em, $this->responseTagger); - } - - public function testDoesntVoteWhenAttributeWrong() { - // given - - // when - $result = $this->voter->vote($this->token, new Period(), ['CAMP_IS_SOMETHING_ELSE']); - - // then - $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); - } - - public function testDoesntVoteWhenSubjectDoesNotBelongToCamp() { - // given - - // when - $result = $this->voter->vote($this->token, new CampIsPrototypeVoterTestDummy(), ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); - } - - public function testDoesntVoteWhenSubjectIsNull() { - // given - - // when - $result = $this->voter->vote($this->token, null, ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); - } - - public function testDeniesAccessWhenGetCampYieldsNull() { - // given - $this->token->method('getUser')->willReturn(new User()); - $subject = $this->createMock(Period::class); - $subject->method('getCamp')->willReturn(null); - - // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_DENIED, $result); - } - - public function testDeniesAccessWhenCampIsntPrototype() { - // given - $user = $this->createMock(User::class); - $user->method('getId')->willReturn('idFromTest'); - $this->token->method('getUser')->willReturn($user); - $camp = new Camp(); - $camp->isPrototype = false; - $subject = $this->createMock(Period::class); - $subject->method('getCamp')->willReturn($camp); - - // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_DENIED, $result); - } - - public function testGrantsAccessViaBelongsToCampInterface() { - // given - $user = $this->createMock(User::class); - $user->method('getId')->willReturn('idFromTest'); - $this->token->method('getUser')->willReturn($user); - $camp = new Camp(); - $camp->isPrototype = true; - $subject = $this->createMock(Period::class); - $subject->method('getCamp')->willReturn($camp); - - $this->responseTagger->expects($this->once())->method('addTags')->with([$camp->getId()]); - - // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); - } - - public function testGrantsAccessViaBelongsToContentNodeTreeInterface() { - // given - $user = $this->createMock(User::class); - $user->method('getId')->willReturn('idFromTest'); - $this->token->method('getUser')->willReturn($user); - $camp = new Camp(); - $camp->isPrototype = true; - $activity = $this->createMock(Activity::class); - $activity->method('getCamp')->willReturn($camp); - $root = $this->createMock(ColumnLayout::class); - $subject = $this->createMock(ContentNodeTreeDummy1::class); - $subject->method('getRoot')->willReturn($root); - $repository = $this->createMock(EntityRepository::class); - $this->em->method('getRepository')->willReturn($repository); - $repository->method('findOneBy')->willReturn($activity); - - // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); - - // then - $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); - } -} - -class CampIsPrototypeVoterTestDummy extends BaseEntity {} - -class ContentNodeTreeDummy1 implements BelongsToContentNodeTreeInterface { - public function getRoot(): ?ColumnLayout { - return null; - } -} diff --git a/api/tests/Security/Voter/CampIsSharedVoterTest.php b/api/tests/Security/Voter/CampIsPublicVoterTest.php similarity index 88% rename from api/tests/Security/Voter/CampIsSharedVoterTest.php rename to api/tests/Security/Voter/CampIsPublicVoterTest.php index 0bc55306cf..7c360b57e7 100644 --- a/api/tests/Security/Voter/CampIsSharedVoterTest.php +++ b/api/tests/Security/Voter/CampIsPublicVoterTest.php @@ -10,7 +10,7 @@ use App\Entity\Period; use App\Entity\User; use App\HttpCache\ResponseTagger; -use App\Security\Voter\CampIsSharedVoter; +use App\Security\Voter\CampIsPublicVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\MockObject\MockObject; @@ -21,8 +21,8 @@ /** * @internal */ -class CampIsSharedVoterTest extends TestCase { - private CampIsSharedVoter $voter; +class CampIsPublicVoterTest extends TestCase { + private CampIsPublicVoter $voter; private MockObject|TokenInterface $token; private EntityManagerInterface|MockObject $em; private MockObject|ResponseTagger $responseTagger; @@ -32,7 +32,7 @@ public function setUp(): void { $this->token = $this->createMock(TokenInterface::class); $this->em = $this->createMock(EntityManagerInterface::class); $this->responseTagger = $this->createMock(ResponseTagger::class); - $this->voter = new CampIsSharedVoter($this->em, $this->responseTagger); + $this->voter = new CampIsPublicVoter($this->em, $this->responseTagger); } public function testDoesntVoteWhenAttributeWrong() { @@ -49,7 +49,7 @@ public function testDoesntVoteWhenSubjectDoesNotBelongToCamp() { // given // when - $result = $this->voter->vote($this->token, new CampIsSharedVoterTestDummy(), ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, new CampIsPublicVoterTestDummy(), ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); @@ -59,7 +59,7 @@ public function testDoesntVoteWhenSubjectIsNull() { // given // when - $result = $this->voter->vote($this->token, null, ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, null, ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); @@ -72,24 +72,24 @@ public function testDeniesAccessWhenGetCampYieldsNull() { $subject->method('getCamp')->willReturn(null); // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_DENIED, $result); } - public function testDeniesAccessWhenCampIsntShared() { + public function testDeniesAccessWhenCampIsntPublic() { // given $user = $this->createMock(User::class); $user->method('getId')->willReturn('idFromTest'); $this->token->method('getUser')->willReturn($user); $camp = new Camp(); - $camp->isShared = false; + $camp->isPublic = false; $subject = $this->createMock(Period::class); $subject->method('getCamp')->willReturn($camp); // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_DENIED, $result); @@ -101,14 +101,14 @@ public function testGrantsAccessViaBelongsToCampInterface() { $user->method('getId')->willReturn('idFromTest'); $this->token->method('getUser')->willReturn($user); $camp = new Camp(); - $camp->isShared = true; + $camp->isPublic = true; $subject = $this->createMock(Period::class); $subject->method('getCamp')->willReturn($camp); $this->responseTagger->expects($this->once())->method('addTags')->with([$camp->getId()]); // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); @@ -120,7 +120,7 @@ public function testGrantsAccessViaBelongsToContentNodeTreeInterface() { $user->method('getId')->willReturn('idFromTest'); $this->token->method('getUser')->willReturn($user); $camp = new Camp(); - $camp->isShared = true; + $camp->isPublic = true; $activity = $this->createMock(Activity::class); $activity->method('getCamp')->willReturn($camp); $root = $this->createMock(ColumnLayout::class); @@ -131,14 +131,14 @@ public function testGrantsAccessViaBelongsToContentNodeTreeInterface() { $repository->method('findOneBy')->willReturn($activity); // when - $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_SHARED']); + $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PUBLIC']); // then $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); } } -class CampIsSharedVoterTestDummy extends BaseEntity {} +class CampIsPublicVoterTestDummy extends BaseEntity {} class ContentNodeTreeDummy3 implements BelongsToContentNodeTreeInterface { public function getRoot(): ?ColumnLayout { diff --git a/frontend/src/components/activity/ScheduleEntry.vue b/frontend/src/components/activity/ScheduleEntry.vue index 23d0abf008..76701797cb 100644 --- a/frontend/src/components/activity/ScheduleEntry.vue +++ b/frontend/src/components/activity/ScheduleEntry.vue @@ -269,7 +269,7 @@ Displays a single scheduleEntry /> - + - - @@ -88,6 +93,7 @@ import DayResponsibles from './DayResponsibles.vue' import { ONE_DAY_IN_MILLISECONDS } from '@/helpers/vCalendarDragAndDrop.js' import { errorToMultiLineToast } from '@/components/toast/toasts' import PicassoEntry from './PicassoEntry.vue' +import { campRoleMixin } from '@/mixins/campRoleMixin.js' export default { name: 'Picasso', @@ -95,6 +101,7 @@ export default { PicassoEntry, DayResponsibles, }, + mixins: [campRoleMixin], props: { // period for which to show picasso period: { diff --git a/frontend/src/views/camp/CampProgram.vue b/frontend/src/views/camp/CampProgram.vue index 28b80f728d..7a41a5e640 100644 --- a/frontend/src/views/camp/CampProgram.vue +++ b/frontend/src/views/camp/CampProgram.vue @@ -94,6 +94,7 @@ Show all activity schedule entries of a single period. :camp="camp" :filter-fn="filterFn" :hide-self-filter="isOutsider" + :hide-collaborator-filter="isOutsider" hide-period-filter hide-day-filter @height-changed="scheduleEntryFiltersHeightChanged" diff --git a/frontend/src/views/camp/Dashboard.vue b/frontend/src/views/camp/Dashboard.vue index be2763a177..b47ba05d22 100644 --- a/frontend/src/views/camp/Dashboard.vue +++ b/frontend/src/views/camp/Dashboard.vue @@ -15,6 +15,7 @@ :loading-endpoints="true" :camp="camp" :hide-self-filter="isOutsider" + :hide-collaborator-filter="isOutsider" hide-day-filter :periods="periods" /> @@ -27,6 +28,7 @@ :camp="camp" :periods="periods" :hide-self-filter="isOutsider" + :hide-collaborator-filter="isOutsider" hide-day-filter :filter-fn="filterFn" /> diff --git a/frontend/src/views/camp/admin/SideBarAdmin.vue b/frontend/src/views/camp/admin/SideBarAdmin.vue index 222afbdb14..b1ca29b677 100644 --- a/frontend/src/views/camp/admin/SideBarAdmin.vue +++ b/frontend/src/views/camp/admin/SideBarAdmin.vue @@ -13,6 +13,7 @@ icon="mdi-view-dashboard-outline" /> - + - +