diff --git a/app/AnnotationGuideline.php b/app/AnnotationGuideline.php new file mode 100644 index 0000000000..03a8f38381 --- /dev/null +++ b/app/AnnotationGuideline.php @@ -0,0 +1,72 @@ + + */ + protected $casts = [ + 'id' => 'int', + 'project_id' => 'int', + 'description' => 'string', + 'enforced' => 'boolean', + 'only_shapes' => 'array', + ]; + + protected $fillable = [ + 'project_id', + 'description', + 'enforced', + 'only_shapes', + ]; + + protected static function booted(): void + { + static::deleting(function (self $guideline) { + // Defer storage deletion until after the DB transaction commits to avoid + // deleting files if the transaction rolls back. + DB::afterCommit(function () use ($guideline) { + Storage::disk(config('projects.annotation_guideline_disk')) + ->deleteDirectory("$guideline->id"); + }); + }); + } + + /** + * The project this guideline belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function project() + { + return $this->belongsTo(Project::class); + } + + /** + * The labels within this guideline. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function labels() + { + return $this->belongsToMany(Label::class) + ->using(AnnotationGuidelineLabel::class) + ->withPivot('shape_id', 'description', 'uuid', 'reference_image_path'); + } +} diff --git a/app/AnnotationGuidelineLabel.php b/app/AnnotationGuidelineLabel.php new file mode 100644 index 0000000000..d23e3a0d47 --- /dev/null +++ b/app/AnnotationGuidelineLabel.php @@ -0,0 +1,73 @@ + + */ + protected $casts = [ + 'label_id' => 'int', + 'shape_id' => 'int', + 'annotation_guideline_id' => 'int', + ]; + + protected static function booted(): void + { + static::deleting(function (self $guidelineLabel) { + if (is_null($guidelineLabel->reference_image_path)) { + return; + } + + // Defer storage deletion until after the DB transaction commits to avoid + // deleting files if the transaction rolls back. + DB::afterCommit(function () use ($guidelineLabel) { + Storage::disk(config('projects.annotation_guideline_disk')) + ->delete($guidelineLabel->reference_image_path); + }); + }); + } + + public function getReferenceImageUrlAttribute(): ?string + { + if (is_null($this->reference_image_path)) { + return null; + } + + return Storage::disk(config('projects.annotation_guideline_disk')) + ->url($this->reference_image_path); + } + + /** + * The defined shape for this label. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function shape() + { + return $this->belongsTo(Shape::class); + } +} diff --git a/app/Http/Controllers/Api/ProjectLabelTreeController.php b/app/Http/Controllers/Api/ProjectLabelTreeController.php index 3498e0929d..d55ecf6864 100644 --- a/app/Http/Controllers/Api/ProjectLabelTreeController.php +++ b/app/Http/Controllers/Api/ProjectLabelTreeController.php @@ -2,10 +2,12 @@ namespace Biigle\Http\Controllers\Api; +use Biigle\AnnotationGuidelineLabel; +use Biigle\Http\Requests\DestroyProjectLabelTree; use Biigle\Http\Requests\StoreProjectLabelTree; use Biigle\LabelTree; use Biigle\Project; -use Illuminate\Http\Request; +use DB; class ProjectLabelTreeController extends Controller { @@ -139,24 +141,31 @@ public function store(StoreProjectLabelTree $request) /** * Removes a label tree from the specified project. * - * @api {delete} projects/:pid/label-trees/:lid Remove a label tree + * @api {delete} projects/:id/label-trees/:id2 Remove a label tree * @apiGroup Projects * @apiName DetachProjectLabelTrees * @apiPermission projectAdmin + * @apiDescription Returns 409 if the label tree has annotation guideline labels in this project. Use the `force` argument to detach the label tree and delete the guideline labels. * - * @apiParam {Number} pid The project ID. - * @apiParam {Number} lid The label tree ID. + * @apiParam {Number} id The project ID. + * @apiParam {Number} id2 The label tree ID. + * + * @apiParam (Optional attributes) {Boolean} force Delete annotation guideline labels belonging to the label tree before detaching. * - * @param Request $request - * @param int $pid - * @param int $lid + * @param DestroyProjectLabelTree $request * @return \Illuminate\Http\RedirectResponse|void */ - public function destroy(Request $request, $pid, $lid) + public function destroy(DestroyProjectLabelTree $request) { - $project = Project::findOrFail($pid); - $this->authorize('update', $project); - $count = $project->labelTrees()->detach($lid); + $count = DB::transaction(function () use ($request) { + $treeId = $request->route('id2'); + AnnotationGuidelineLabel::join('annotation_guidelines', 'annotation_guidelines.id', '=', 'annotation_guideline_label.annotation_guideline_id') + ->where('annotation_guidelines.project_id', $request->project->id) + ->whereIn('label_id', fn ($q) => $q->select('id')->from('labels')->where('label_tree_id', $treeId)) + ->each(fn ($gl) => $gl->delete()); + + return $request->project->labelTrees()->detach($treeId); + }); if (!$this->isAutomatedRequest()) { return $this->fuzzyRedirect()->with('deleted', $count > 0); diff --git a/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php b/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php new file mode 100644 index 0000000000..5865af6dc4 --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php @@ -0,0 +1,176 @@ +authorize('access', $project); + + $guideline = $project->annotationGuideline; + + if (!$guideline) { + abort(Response::HTTP_NOT_FOUND); + } + + return $guideline->load('labels'); + } + + /** + * Create the annotation guideline for the given project. + * + * @api {post} projects/:id/annotation-guidelines Create the annotation guideline + * @apiGroup Projects + * @apiName StoreAnnotationGuideline + * @apiParam {Number} id The project ID + * @apiParam {String} [description] Description of how to annotate. + * @apiParam {Boolean} [enforced] Whether the guideline restricts the available labels and shapes for new annotations. + * @apiParam {Number[]} [only_shapes] IDs of the shapes that should be available for new annotations. If empty, all shapes are available. Can only be set if enforced is true. + * @apiPermission projectAdmin + */ + public function store(Request $request, int $id) + { + $project = Project::findOrFail($id); + $this->authorize('update', $project); + + $request->validate([ + 'description' => 'nullable|string|min:1', + 'enforced' => 'boolean', + 'only_shapes' => 'nullable|array|prohibited_unless:enforced,true', + 'only_shapes.*' => 'distinct|integer|exists:shapes,id', + ]); + + $guideline = AnnotationGuideline::firstOrCreate( + [ + 'project_id' => $project->id, + ], + [ + 'description' => $request->description, + 'enforced' => $request->boolean('enforced'), + 'only_shapes' => $this->castOnlyShapes($request->only_shapes), + ], + ); + + if (!$guideline->wasRecentlyCreated) { + throw ValidationException::withMessages([ + 'id' => 'The project already has an annotation guideline.', + ]); + } + + return $guideline; + } + + /** + * Update the annotation guideline. + * + * @api {put} annotation-guidelines/:id Update the annotation guideline + * @apiGroup Projects + * @apiName UpdateAnnotationGuideline + * @apiParam {Number} id The guideline ID + * @apiParam {String} [description] Description of how to annotate. + * @apiParam {Boolean} [enforced] Whether the guideline restricts the available labels and shapes for new annotations. + * @apiParam {Number[]} [only_shapes] IDs of the shapes that should be available for new annotations. If empty, all shapes are available. Can only be set if enforced is true. + * @apiPermission projectAdmin + */ + public function update(Request $request, int $id) + { + $guideline = AnnotationGuideline::findOrFail($id); + $this->authorize('update', $guideline->project); + + $request->validate([ + 'description' => 'nullable|string|min:1', + 'enforced' => 'boolean', + 'only_shapes' => 'nullable|array|prohibited_unless:enforced,true', + 'only_shapes.*' => 'distinct|integer|exists:shapes,id', + ]); + + $onlyShapes = $this->castOnlyShapes($request->only_shapes); + + $guideline->update([ + 'description' => $request->description, + 'enforced' => $request->boolean('enforced'), + 'only_shapes' => $onlyShapes, + ]); + + if ($request->exists('only_shapes') && !is_null($onlyShapes)) { + AnnotationGuidelineLabel::where('annotation_guideline_id', $guideline->id) + ->whereNotNull('shape_id') + ->whereNotIn('shape_id', $onlyShapes) + ->update(['shape_id' => null]); + } + } + + /** + * Cast the IDs of the only_shapes attribute to int. + * + * @param array|null $onlyShapes + * + * @return array|null + */ + protected function castOnlyShapes(?array $onlyShapes) + { + if (empty($onlyShapes)) { + return null; + } + + return array_map('intval', $onlyShapes); + } + + /** + * Delete the annotation guideline. + * + * @api {delete} annotation-guidelines/:id Delete the annotation guideline + * @apiGroup Projects + * @apiName DestroyAnnotationGuideline + * @apiParam {Number} id The guideline ID + * @apiPermission projectAdmin + */ + public function destroy(int $id) + { + $guideline = AnnotationGuideline::findOrFail($id); + $this->authorize('update', $guideline->project); + // Wrap in a transaction so DB::afterCommit() in the guideline model defers + // storage deletion until the DB delete is committed. + DB::transaction(fn () => $guideline->delete()); + } +} diff --git a/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php b/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php new file mode 100644 index 0000000000..bd1687edee --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php @@ -0,0 +1,103 @@ +guideline->id; + $validated = $request->validated(); + + $labelId = $validated['label_id']; + $shapeId = $validated['shape_id'] ?? null; + $description = $validated['description'] ?? null; + + $label = $request->guideline->labels() + ->where('label_id', $labelId) + ->first(); + + if ($label) { + $guidelineLabel = $label->pivot; + $guidelineLabel->update([ + 'shape_id' => $shapeId, + 'description' => $description, + ]); + } else { + $guidelineLabel = AnnotationGuidelineLabel::create([ + 'annotation_guideline_id' => $guidelineId, + 'label_id' => $labelId, + 'shape_id' => $shapeId, + 'description' => $description, + 'uuid' => Str::uuid(), + ]); + } + + if (array_key_exists('reference_image', $validated)) { + $disk = Storage::disk(config('projects.annotation_guideline_disk')); + $image = $validated['reference_image']; + if ($image) { + $path = "{$guidelineId}/{$guidelineLabel->uuid}.jpg"; + if ($disk->putFileAs("$guidelineId", $image, "{$guidelineLabel->uuid}.jpg") === false) { + abort(500, 'The reference image could not be stored.'); + } + $guidelineLabel->update(['reference_image_path' => $path]); + } elseif (!is_null($guidelineLabel->reference_image_path)) { + $path = $guidelineLabel->reference_image_path; + // Defer storage deletion until after the DB transaction commits to + // avoid deleting the file if the transaction rolls back. + DB::afterCommit(fn () => $disk->delete($path)); + $guidelineLabel->update(['reference_image_path' => null]); + } + } + + return $guidelineLabel; + }); + } + + /** + * Delete a label from an annotation guideline. + * + * @api {delete} annotation-guidelines/:id/labels/:label Delete a label from an annotation guideline + * @apiGroup Projects + * @apiName DestroyAnnotationGuidelineLabel + * @apiPermission projectAdmin + */ + public function destroy(int $guidelineId, int $labelId) + { + $guideline = AnnotationGuideline::findOrFail($guidelineId); + $this->authorize('update', $guideline); + + $label = $guideline->labels() + ->where('label_id', $labelId) + ->firstOrFail(); + + // Wrap in a transaction so DB::afterCommit() in the guideline label model + // defers storage deletion until the DB delete is committed. + DB::transaction(fn () => $label->pivot->delete()); + } +} diff --git a/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php b/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php new file mode 100644 index 0000000000..19830522bf --- /dev/null +++ b/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php @@ -0,0 +1,65 @@ +user(); + + if (!$user->can('sudo')) { + $this->authorize('access', $project); + } + + $isAdmin = $user->can('update', $project); + + $annotationGuideline = $project->annotationGuideline; + + if ($annotationGuideline) { + $annotationGuideline->load('labels'); + } elseif (!$isAdmin) { + abort(Response::HTTP_NOT_FOUND); + } + + $userProject = $request->user()->projects()->where('id', $id)->first(); + $isMember = $userProject !== null; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; + $canPin = $isMember && 3 > $request->user() + ->projects() + ->wherePivot('pinned', true) + ->count(); + + $labelTrees = $project->labelTrees()->with('labels', 'version')->get(); + + $shapes = Shape::pluck('name', 'id'); + + return view('projects.show.annotation-guideline', [ + 'project' => $project, + 'user' => $user, + 'annotationGuideline' => $annotationGuideline, + 'isMember' => $isMember, + 'isAdmin' => $isAdmin, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'guideline', + 'labelTrees' => $labelTrees, + 'availableShapes' => $shapes, + ]); + } +} diff --git a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php index 05937e5190..b3b21c1a94 100644 --- a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php @@ -37,7 +37,6 @@ public function show(Request $request, $id) ->orderBy('created_at', 'desc') ->get(); - $totalImages = Image::whereIn('images.volume_id', fn ($query) => $query->select('volume_id') ->from('project_volume') ->where('project_id', $project->id))->count(); @@ -58,6 +57,7 @@ public function show(Request $request, $id) ->where('media_type_id', MediaType::videoId()) ->get(); + return view('projects.show.statistics', [ 'project' => $project, 'isMember' => $isMember, diff --git a/app/Http/Requests/DestroyProjectLabelTree.php b/app/Http/Requests/DestroyProjectLabelTree.php new file mode 100644 index 0000000000..450d6efe6d --- /dev/null +++ b/app/Http/Requests/DestroyProjectLabelTree.php @@ -0,0 +1,46 @@ +project = Project::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->project); + } + + public function rules() + { + return [ + 'force' => 'nullable|boolean', + ]; + } + + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($this->input('force')) { + return; + } + + $treeId = $this->route('id2'); + $hasGuidelineLabels = AnnotationGuidelineLabel::join('annotation_guidelines', 'annotation_guidelines.id', '=', 'annotation_guideline_label.annotation_guideline_id') + ->where('annotation_guidelines.project_id', $this->project->id) + ->whereIn('label_id', fn ($q) => $q->select('id')->from('labels')->where('label_tree_id', $treeId)) + ->exists(); + + if ($hasGuidelineLabels) { + abort(Response::HTTP_CONFLICT, 'The label tree has annotation guideline labels that would be deleted. Use the "force" argument to detach and delete them.'); + } + }); + } +} diff --git a/app/Http/Requests/StoreAnnotationGuidelineLabel.php b/app/Http/Requests/StoreAnnotationGuidelineLabel.php new file mode 100644 index 0000000000..0947c9418a --- /dev/null +++ b/app/Http/Requests/StoreAnnotationGuidelineLabel.php @@ -0,0 +1,74 @@ +guideline = AnnotationGuideline::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->guideline); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $belongsToProject = Label::where('id', $this->input('label_id')) + ->whereIn('label_tree_id', fn ($q) => $q->select('label_tree_id')->from('label_tree_project')->where('project_id', $this->guideline->project_id)) + ->exists(); + + if (!$belongsToProject) { + $validator->errors()->add('label_id', 'The label does not belong to a label tree of this project.'); + } + }); + } + + /** + * The rules that the request should follow + * + * @return array + */ + public function rules(): array + { + return [ + 'label_id' => ['required', 'integer', 'exists:labels,id'], + 'description' => ['nullable', 'string'], + 'shape_id' => [ + 'nullable', + 'integer', + 'exists:shapes,id', + Rule::when($this->guideline->enforced && !is_null($this->guideline->only_shapes), [Rule::in($this->guideline->only_shapes)]), + ], + 'reference_image' => [ + 'nullable', + 'file', + 'mimes:jpg', + 'max:5120', + 'dimensions:max_width=1000,max_height=1000', + ], + ]; + } +} diff --git a/app/Label.php b/app/Label.php index 6e44f3a1a7..e46eb3fcee 100644 --- a/app/Label.php +++ b/app/Label.php @@ -2,8 +2,10 @@ namespace Biigle; +use Biigle\Observers\LabelObserver; use DB; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\WithoutTimestamps; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -20,6 +22,7 @@ * @property string $uuid */ #[Hidden(['uuid'])] +#[ObservedBy(LabelObserver::class)] #[WithoutTimestamps] class Label extends Model { diff --git a/app/LabelTree.php b/app/LabelTree.php index bb97a7b46c..76fba5fe26 100644 --- a/app/LabelTree.php +++ b/app/LabelTree.php @@ -2,8 +2,10 @@ namespace Biigle; +use Biigle\Observers\LabelTreeObserver; use DB; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Ramsey\Uuid\Uuid; @@ -19,6 +21,7 @@ * @property string $uuid */ #[Hidden(['pivot', 'uuid', 'version_id'])] +#[ObservedBy(LabelTreeObserver::class)] class LabelTree extends Model { use HasFactory; diff --git a/app/Observers/LabelObserver.php b/app/Observers/LabelObserver.php new file mode 100644 index 0000000000..2cce3a8e24 --- /dev/null +++ b/app/Observers/LabelObserver.php @@ -0,0 +1,20 @@ + + AnnotationGuidelineLabel::where('label_id', $label->id)->each(fn ($gl) => $gl->delete()) + ); + } +} diff --git a/app/Observers/LabelTreeObserver.php b/app/Observers/LabelTreeObserver.php new file mode 100644 index 0000000000..5d26b7bd6c --- /dev/null +++ b/app/Observers/LabelTreeObserver.php @@ -0,0 +1,21 @@ + + AnnotationGuidelineLabel::whereIn('label_id', fn ($q) => $q->select('id')->from('labels')->where('label_tree_id', $labelTree->id)) + ->each(fn ($gl) => $gl->delete()) + ); + } +} diff --git a/app/Observers/ProjectObserver.php b/app/Observers/ProjectObserver.php index 0afd525a16..ce864b5021 100644 --- a/app/Observers/ProjectObserver.php +++ b/app/Observers/ProjectObserver.php @@ -6,16 +6,15 @@ use Biigle\Project; use Biigle\Report; use Biigle\Role; +use DB; use Exception; class ProjectObserver { /** * A project must not be created without having a creator. - * @param \Biigle\Project $project - * */ - public function creating($project) + public function creating(Project $project) { if ($project->creator_id === null) { throw new Exception('Project creator must not be null when creating a new project.'); @@ -24,11 +23,8 @@ public function creating($project) /** * Handle actions for newly created projects. - * - * @param \Biigle\Project $project - * @return void */ - public function created($project) + public function created(Project $project) { // set creator as project admin // this must be done *after* the project is saved so it already has an id @@ -41,12 +37,18 @@ public function created($project) $project->labelTrees()->attach($ids); } + public function deleting(Project $project) + { + // Delete manually so the reference image files are cleaned. + // Wrap in a transaction so DB::afterCommit() in the guideline model defers + // storage deletion until the DB delete is committed. + DB::transaction(fn () => $project->annotationGuideline?->delete()); + } + /** * Update the source name of reports when the source is deleted. - * - * @param \Biigle\Project $project */ - public function deleted($project) + public function deleted(Project $project) { Report::where('source_id', '=', $project->id) ->where('source_type', '=', Project::class) diff --git a/app/Policies/AnnotationGuidelinePolicy.php b/app/Policies/AnnotationGuidelinePolicy.php new file mode 100644 index 0000000000..9600e2375f --- /dev/null +++ b/app/Policies/AnnotationGuidelinePolicy.php @@ -0,0 +1,33 @@ +can('sudo')) { + return true; + } + } + + /** + * Determine if the user can edit the given annotation guideline. + */ + public function update(User $user, AnnotationGuideline $guideline): bool + { + return $this->remember("annotation-guideline-can-update-{$user->id}-{$guideline->id}", fn () => DB::table('project_user') + ->where('project_id', $guideline->project_id) + ->where('project_role_id', Role::adminId()) + ->where('user_id', $user->id) + ->exists()); + } +} diff --git a/app/Project.php b/app/Project.php index 800d92d1a4..1e49885cfb 100644 --- a/app/Project.php +++ b/app/Project.php @@ -340,4 +340,14 @@ public function flushGeoInfoCache() { Cache::forget("project-{$this->id}-has-geo-info"); } + + /** + * The annotation guideline of this project. + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function annotationGuideline() + { + return $this->hasOne(AnnotationGuideline::class); + } } diff --git a/config/filesystems.php b/config/filesystems.php index 6b5ba19011..2895e800aa 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -14,9 +14,7 @@ | Supported: "local", "ftp", "sftp", "s3" | */ - 'default' => env('FILESYSTEM_DISK', env('FILESYSTEM_DRIVER', 'local')), - /* |-------------------------------------------------------------------------- | Filesystem Disks @@ -95,6 +93,13 @@ 'url' => env('APP_URL').'/storage/largo-patches', 'visibility' => 'public', ], + + 'annotation-guideline' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/annotation-guidelines'), + 'url' => env('APP_URL').'/storage/annotation-guidelines', + 'visibility' => 'public', + ], ], /* diff --git a/config/projects.php b/config/projects.php new file mode 100644 index 0000000000..936def96e6 --- /dev/null +++ b/config/projects.php @@ -0,0 +1,9 @@ + env('PROJECTS_ANNOTATION_GUIDELINE_DISK', 'annotation-guideline'), +]; diff --git a/database/factories/AnnotationGuidelineFactory.php b/database/factories/AnnotationGuidelineFactory.php new file mode 100644 index 0000000000..16db8c70ad --- /dev/null +++ b/database/factories/AnnotationGuidelineFactory.php @@ -0,0 +1,24 @@ + Project::factory(), + 'description' => null, + 'enforced' => false, + 'only_shapes' => null, + ]; + } +} diff --git a/database/factories/AnnotationGuidelineLabelFactory.php b/database/factories/AnnotationGuidelineLabelFactory.php new file mode 100644 index 0000000000..0d4285ad2a --- /dev/null +++ b/database/factories/AnnotationGuidelineLabelFactory.php @@ -0,0 +1,22 @@ + AnnotationGuideline::factory(), + 'label_id' => Label::factory(), + 'shape_id' => null, + 'description' => null, + 'uuid' => $this->faker->unique()->uuid(), + 'reference_image_path' => null, + ]; + } +} diff --git a/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php b/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php new file mode 100644 index 0000000000..5929163188 --- /dev/null +++ b/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php @@ -0,0 +1,62 @@ +id(); + $table->timestamps(); + $table->foreignId('project_id') + ->constrained() + ->onDelete('cascade'); + $table->unique('project_id'); + + $table->boolean('enforced')->default(false); + + $table->text('description')->nullable(); + $table->jsonb('only_shapes')->nullable(); + }); + + Schema::create('annotation_guideline_label', function (Blueprint $table) { + $table->uuid('uuid')->primary(); + $table->timestamps(); + + $table->foreignId('annotation_guideline_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('label_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('shape_id') + ->nullable() + ->constrained() + ->onDelete('set null'); + + $table->text('description')->nullable(); + + $table->string('reference_image_path')->nullable(); + + $table->index('annotation_guideline_id'); + $table->index('label_id'); + $table->unique(['annotation_guideline_id', 'label_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('annotation_guideline_label'); + Schema::dropIfExists('annotation_guidelines'); + } +}; diff --git a/resources/assets/js/label-trees/components/labelTree.vue b/resources/assets/js/label-trees/components/labelTree.vue index dc9b8640b8..c60b6c8538 100644 --- a/resources/assets/js/label-trees/components/labelTree.vue +++ b/resources/assets/js/label-trees/components/labelTree.vue @@ -43,6 +43,8 @@ :flat="flat" :showFavouriteShortcuts="showFavouriteShortcuts" :position="index" + :labels-in-guideline="labelsInGuideline" + :filter-by-guideline="filterByGuideline" :can-have-more-favourites="canHaveMoreFavourites" @select="emitSelect" @deselect="emitDeselect" @@ -150,6 +152,14 @@ export default { type: Boolean, default: false, }, + labelsInGuideline: { + type: Set, + default: () => new Set(), + }, + filterByGuideline: { + type: Boolean, + default: false, + }, canHaveMoreFavourites: { type: Boolean, default: true, @@ -191,11 +201,23 @@ export default { label.open = false; } }); + + const hasChildrenInGuideline = (children) => { + return children?.some(child => this.labelsInGuideline.has(child.id) || hasChildrenInGuideline(child.children)); + }; + + this.labels.forEach(function (label) { + label.childrenInGuideline = hasChildrenInGuideline(label.children); + }); } return compiled; }, rootLabels() { + if (this.filterByGuideline) { + return this.compiledLabels[null]?.filter(label => this.labelsInGuideline.has(label.id) || label.childrenInGuideline); + } + return this.compiledLabels[null]; }, collapseTitle() { @@ -390,7 +412,7 @@ export default { }, created() { // Set the reactive label properties - this.labels.forEach(function (label) { + this.labels.forEach((label) => { if (!label.hasOwnProperty('open')) { label.open = false; } diff --git a/resources/assets/js/label-trees/components/labelTreeLabel.vue b/resources/assets/js/label-trees/components/labelTreeLabel.vue index 05d476b6a8..3674439783 100644 --- a/resources/assets/js/label-trees/components/labelTreeLabel.vue +++ b/resources/assets/js/label-trees/components/labelTreeLabel.vue @@ -1,6 +1,6 @@ @@ -83,9 +99,17 @@ export default { type: Boolean, default: false, }, - position:{ + position: { type: Number, - default:-1, + default: -1, + }, + labelsInGuideline: { + type: Set, + default: () => new Set(), + }, + filterByGuideline: { + type: Boolean, + default: false, }, canHaveMoreFavourites: { type: Boolean, @@ -124,6 +148,11 @@ export default { 'selected': this.label.favourite, }; }, + nameClass() { + return { + 'text-muted': this.hasGuideline && !this.isInGuideline, + }; + }, favouriteTitle() { if (this.cantBeAddedAsFavourite) { return `You cannot add more than ${MAX_FAVOURITES} favourite labels`; @@ -141,14 +170,33 @@ export default { return 'Remove label ' + this.label.name; }, expandable() { - return !this.flat && !!this.label.children; + return !this.flat && (this.filteredChildren && this.filteredChildren.length > 0); }, showEditButton() { return this.editable && this.hover && !this.editing; }, actualPosition() { return (this.position + 1) % MAX_FAVOURITES; - } + }, + hasGuideline() { + return this.labelsInGuideline.size > 0; + }, + isInGuideline() { + return this.labelsInGuideline.has(this.label.id); + }, + shouldBeFilteredByGuideline() { + return this.filterByGuideline && !this.isInGuideline && !this.label.childrenInGuideline; + }, + showGuidelineIcon() { + return this.hasGuideline && this.isInGuideline && !this.filterByGuideline + }, + filteredChildren() { + if (this.filterByGuideline) { + return this.label.children?.filter(child => this.labelsInGuideline.has(child.id) || child.childrenInGuideline); + } + + return this.label.children; + }, }, methods: { toggleSelect(e) { diff --git a/resources/assets/js/label-trees/components/labelTrees.vue b/resources/assets/js/label-trees/components/labelTrees.vue index 12b04d954a..85dabddda3 100644 --- a/resources/assets/js/label-trees/components/labelTrees.vue +++ b/resources/assets/js/label-trees/components/labelTrees.vue @@ -21,18 +21,30 @@ :items="labels" @select="handleSelect" > +
[], + }, }, computed: { customOrderStorageKeys() { @@ -164,6 +183,12 @@ export default { return false; }, + labelsInGuidelineSet() { + return new Set(this.labelsInGuideline); + }, + hasGuideline() { + return this.labelsInGuidelineSet.size > 0; + }, // All labels of all label trees in a flat, sorted list. labels() { let labels = []; @@ -171,8 +196,12 @@ export default { Array.prototype.push.apply(labels, tree.labels); }); + if (this.filterByGuideline) { + labels = labels.filter(label => this.labelsInGuidelineSet.has(label.id)); + } + if (this.localeCompareSupportsLocales) { - // Use this to sort label names "natuarally". This is only supported in + // Use this to sort label names "naturally". This is only supported in // modern browsers, though. let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); labels.sort(function (a, b) { @@ -192,9 +221,19 @@ export default { canHaveMoreFavourites() { return this.favourites.length < MAX_FAVOURITES; }, + filteredFavourites() { + if (this.filterByGuideline) { + return this.favourites.filter(label => this.labelsInGuidelineSet.has(label.id)); + } + + return this.favourites; + }, hasFavourites() { return this.favourites.length > 0; }, + hasFilteredFavourites() { + return this.filteredFavourites.length > 0; + }, ownId() { if (this.id) { return this.id; @@ -216,7 +255,15 @@ export default { }, treeIds() { return this.trees.map(tree => tree.id); - } + }, + filterByGuideline() { + return this.filterByGuidelineInternal && this.hasGuideline; + }, + filterByGuidelineClass() { + return { + 'active btn-info': this.filterByGuideline, + }; + }, }, methods: { handleSelect(label, e) { @@ -258,8 +305,8 @@ export default { } }, selectFavourite(index) { - if (this.favourites[index]) { - this.handleSelect(this.favourites[index]); + if (this.filteredFavourites[index]) { + this.handleSelect(this.filteredFavourites[index]); } }, on(key, fn) { @@ -284,7 +331,10 @@ export default { this.customOrderStorageKeys.forEach(function (storageKey) { localStorage.setItem(storageKey, JSON.stringify(newCustomOrder)); }); - } + }, + toggleFilterLabelsByGuideline() { + this.filterByGuidelineInternal = !this.filterByGuidelineInternal; + }, }, watch: { trees: { diff --git a/resources/assets/js/projects/annotationGuidelineContainer.vue b/resources/assets/js/projects/annotationGuidelineContainer.vue new file mode 100644 index 0000000000..1f90a30a90 --- /dev/null +++ b/resources/assets/js/projects/annotationGuidelineContainer.vue @@ -0,0 +1,9 @@ + diff --git a/resources/assets/js/projects/api/annotationGuideline.js b/resources/assets/js/projects/api/annotationGuideline.js new file mode 100644 index 0000000000..da17c2fb69 --- /dev/null +++ b/resources/assets/js/projects/api/annotationGuideline.js @@ -0,0 +1,27 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for annotation guidelines. + * + * Get the guideline for a project. + * resource.get({id: projectId}).then(...); + * + * Create a guideline for a project. + * resource.save({id: projectId}, {description: '...'}).then(...); + * + * Update a guideline by its ID. + * resource.update({id: guidelineId}, {description: '...'}).then(...); + * + * Delete a guideline by its ID. + * resource.delete({id: guidelineId}).then(...); + */ +export default Resource('api/v1/projects{/id}/annotation-guidelines', {}, { + update: { + method: 'PUT', + url: 'api/v1/annotation-guidelines{/id}', + }, + delete: { + method: 'DELETE', + url: 'api/v1/annotation-guidelines{/id}', + }, +}); diff --git a/resources/assets/js/projects/api/annotationGuidelineLabel.js b/resources/assets/js/projects/api/annotationGuidelineLabel.js new file mode 100644 index 0000000000..01145b8b59 --- /dev/null +++ b/resources/assets/js/projects/api/annotationGuidelineLabel.js @@ -0,0 +1,17 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for the labels within annotation guidelines. + * + * Create or update a label in a guideline. + * resource.save({id: guidelineId}, formData).then(...); + * + * Delete a label from a guideline. + * resource.delete({id: guidelineId, labelId: labelId}).then(...); + */ +export default Resource('api/v1/annotation-guidelines{/id}/labels', {}, { + delete: { + method: 'DELETE', + url: 'api/v1/annotation-guidelines{/id}/labels{/labelId}', + }, +}); diff --git a/resources/assets/js/projects/components/annotationGuideline.vue b/resources/assets/js/projects/components/annotationGuideline.vue new file mode 100644 index 0000000000..9a55b5900a --- /dev/null +++ b/resources/assets/js/projects/components/annotationGuideline.vue @@ -0,0 +1,662 @@ + + + + diff --git a/resources/assets/js/projects/labelTreesContainer.vue b/resources/assets/js/projects/labelTreesContainer.vue index 4a857c13c9..247bdbb9c6 100644 --- a/resources/assets/js/projects/labelTreesContainer.vue +++ b/resources/assets/js/projects/labelTreesContainer.vue @@ -73,6 +73,20 @@ export default { removeTree(tree) { this.startLoading(); ProjectsApi.detachLabelTree({id: this.project.id, label_tree_id: tree.id}) + .then(() => this.treeRemoved(tree), (response) => { + if (response.status === 409) { + if (confirm(`Labels of the "${tree.name}" label tree are part of the annotation guideline of this project. Detach the label tree and delete the labels from the annotation guideline?`)) { + this.forceRemoveTree(tree); + } + } else { + handleErrorResponse(response); + } + }) + .finally(this.finishLoading); + }, + forceRemoveTree(tree) { + this.startLoading(); + ProjectsApi.detachLabelTree({id: this.project.id, label_tree_id: tree.id}, {force: true}) .then(() => this.treeRemoved(tree), handleErrorResponse) .finally(this.finishLoading); }, diff --git a/resources/assets/js/projects/main.js b/resources/assets/js/projects/main.js index de9ff71162..298d2a8253 100644 --- a/resources/assets/js/projects/main.js +++ b/resources/assets/js/projects/main.js @@ -7,6 +7,7 @@ import StatisticsContainer from './statisticsContainer.vue'; import Title from './title.vue'; import VolumesContainer from './volumesContainer.vue'; import VolumesCount from './volumesCount.vue'; +import AnnotationGuidelineContainer from './annotationGuidelineContainer.vue'; biigle.$mount('project-label-trees-count', LabelTreesCount); biigle.$mount('project-members-count', MembersCount); @@ -17,3 +18,4 @@ biigle.$mount('projects-show-members', MembersContainer); biigle.$mount('projects-show-statistics', StatisticsContainer); biigle.$mount('projects-show-volumes', VolumesContainer); biigle.$mount('projects-title', Title); +biigle.$mount('annotation-guideline-container', AnnotationGuidelineContainer); diff --git a/resources/assets/sass/label-trees/components/_labelTrees.scss b/resources/assets/sass/label-trees/components/_labelTrees.scss index b6979fe36a..58c91b204b 100644 --- a/resources/assets/sass/label-trees/components/_labelTrees.scss +++ b/resources/assets/sass/label-trees/components/_labelTrees.scss @@ -22,6 +22,10 @@ &:not(:first-child) { margin-left: 10px; } + + &:not(:last-child) { + margin-right: 10px; + } } .label-tree { diff --git a/resources/views/manual/tutorials/projects/about.blade.php b/resources/views/manual/tutorials/projects/about.blade.php index 6ba15f37e7..59a84766a9 100644 --- a/resources/views/manual/tutorials/projects/about.blade.php +++ b/resources/views/manual/tutorials/projects/about.blade.php @@ -86,6 +86,20 @@ The NetMap Display depicts labels that co-occur with other labels on the same file. Individual labels can be selected to highlight only the connections of this label. Click the button to toggle between the circular and force layouts.

+

Guideline

+ +

+ An annotation guideline can provide detailed instructions and constraints for new annotations in a project. There can be general instructions and instructions for individual labels. The general instructions describe the guideline and can limit the available shape tools for new annotations. The label instructions can describe a label in more detail, choose a single preferred shape tool and provide a reference image. Annotation guidelines can be enforced. If a guideline is enforced, only the selected shape tools and labels are available for new annotations in the project. +

+ +

+ The annotation guideline of a project can be created and edited by the project admin. If there is a guideline for a project, all its members can see the guideline. +

+ +

+ To create a guideline as a project admin, open the tab and add general and/or label instructions. To edit the general instructions, click , update the instructions and save. To edit label instructions, select a label in the label trees list. Then enter a description, choose a preferred shape tool or select a reference image and click . +

+

Pin or modify a project

@@ -101,6 +115,5 @@

Finally, project admins can delete a project with a click on in the dropdown menu. This will detach all label trees and volumes from the project. All volumes that are not attached to another project will be deleted. Be very careful when you want to delete a project since you can destroy lots of annotations with a single action!

-
@endsection diff --git a/resources/views/projects/show/annotation-guideline.blade.php b/resources/views/projects/show/annotation-guideline.blade.php new file mode 100644 index 0000000000..15c138d48f --- /dev/null +++ b/resources/views/projects/show/annotation-guideline.blade.php @@ -0,0 +1,21 @@ +@extends('projects.show.base') + +@push('scripts') + +@endpush + +@section('project-content') +
+
+
+ + +
+
+
+@endsection diff --git a/resources/views/projects/show/tabs.blade.php b/resources/views/projects/show/tabs.blade.php index 25878f1eb3..37c3fc231d 100644 --- a/resources/views/projects/show/tabs.blade.php +++ b/resources/views/projects/show/tabs.blade.php @@ -22,5 +22,11 @@ @endif + @if ($user->can('update', $project) || $project->annotationGuideline()->exists()) + + @endif + @mixin('projectsShowTabs') diff --git a/routes/api.php b/routes/api.php index 7b5320589c..3e5188e36a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -284,6 +284,21 @@ 'uses' => 'Projects\ProjectAnnotationLabels@getProjectAnnotationLabelCounts', ]); +$router->resource('projects.annotation-guidelines', 'Projects\AnnotationGuidelineController', [ + 'only' => ['index', 'store',], + 'parameters' => ['projects' => 'id'], +]); + +$router->resource('annotation-guidelines', 'Projects\AnnotationGuidelineController', [ + 'only' => ['update', 'destroy'], + 'parameters' => ['annotation-guidelines' => 'id'], +]); + +$router->resource('annotation-guidelines.labels', 'Projects\AnnotationGuidelineLabelController', [ + 'only' => ['store', 'destroy'], + 'parameters' => ['annotation-guidelines' => 'id'], +]); + $router->get('public-export/label-trees/{id}', [ 'as' => 'get-public-label-tree-export', 'uses' => 'Export\PublicLabelTreeExportController@show', diff --git a/routes/web.php b/routes/web.php index d1df672a5f..7a095935f2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -281,6 +281,11 @@ 'as' => 'projectsLargo', 'uses' => 'LargoController@index', ]); + + $router->get('{id}/annotation-guideline', [ + 'as' => 'annotation-guideline', + 'uses' => 'AnnotationGuidelineController@show', + ]); }); $router->group(['namespace' => 'Volumes', 'prefix' => 'pending-volumes'], function ($router) { diff --git a/tests/php/AnnotationGuidelineLabelTest.php b/tests/php/AnnotationGuidelineLabelTest.php new file mode 100644 index 0000000000..83c6e8349a --- /dev/null +++ b/tests/php/AnnotationGuidelineLabelTest.php @@ -0,0 +1,87 @@ +guideline = AnnotationGuidelineTest::create(); + $this->label = LabelTest::create(); + } + + public function testAttributes() + { + $pivot = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + ]); + $this->assertSame($this->guideline->id, $pivot->annotation_guideline_id); + $this->assertSame($this->label->id, $pivot->label_id); + $this->assertNull($pivot->shape_id); + $this->assertNull($pivot->description); + $this->assertNotNull($pivot->uuid); + } + + public function testUnique() + { + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + ]); + $this->expectException(QueryException::class); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + ]); + } + + public function testShapeOnDeleteSetNull() + { + $shape = ShapeTest::create(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + 'shape_id' => $shape->id, + ]); + $this->assertNotNull($this->guideline->labels()->first()->pivot->shape_id); + $shape->delete(); + $this->assertNull($this->guideline->labels()->first()->pivot->shape_id); + } + + public function testGuidelineOnDeleteCascade() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + Storage::fake('annotation_storage'); + + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + ]); + $this->guideline->delete(); + $this->assertFalse( + AnnotationGuidelineLabel::where('annotation_guideline_id', $this->guideline->id)->exists() + ); + } + + public function testLabelOnDeleteCascade() + { + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->guideline->id, + 'label_id' => $this->label->id, + ]); + $this->label->delete(); + $this->assertFalse( + AnnotationGuidelineLabel::where('label_id', $this->label->id)->exists() + ); + } +} diff --git a/tests/php/AnnotationGuidelineTest.php b/tests/php/AnnotationGuidelineTest.php new file mode 100644 index 0000000000..fcdb545322 --- /dev/null +++ b/tests/php/AnnotationGuidelineTest.php @@ -0,0 +1,78 @@ +assertNotNull($this->model->project_id); + $this->assertNull($this->model->description); + $this->assertFalse($this->model->enforced); + $this->assertNull($this->model->only_shapes); + $this->assertNotNull($this->model->created_at); + $this->assertNotNull($this->model->updated_at); + } + + public function testProjectRequired() + { + $this->expectException(QueryException::class); + self::create(['project_id' => null]); + } + + public function testProjectUnique() + { + $project = ProjectTest::create(); + self::create(['project_id' => $project->id]); + $this->expectException(QueryException::class); + self::create(['project_id' => $project->id]); + } + + public function testProjectOnDeleteCascade() + { + $project = ProjectTest::create(); + $guideline = self::create(['project_id' => $project->id]); + $project->delete(); + $this->assertNull($guideline->fresh()); + } + + public function testProject() + { + $project = ProjectTest::create(); + $guideline = self::create(['project_id' => $project->id]); + $this->assertSame($project->id, $guideline->project->id); + } + + public function testLabels() + { + $label = LabelTest::create(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->model->id, + 'label_id' => $label->id, + ]); + $this->assertSame($label->id, $this->model->labels()->first()->id); + } + + public function testLabelsOnDeleteCascade() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + Storage::fake('annotation_storage'); + + $label = LabelTest::create(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $this->model->id, + 'label_id' => $label->id, + ]); + $this->model->delete(); + $this->assertFalse($this->model->labels()->exists()); + $this->assertNotNull($label->fresh()); + } +} diff --git a/tests/php/Http/Controllers/Api/ProjectLabelTreeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectLabelTreeControllerTest.php index f8783bb210..82c5a66fc5 100644 --- a/tests/php/Http/Controllers/Api/ProjectLabelTreeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectLabelTreeControllerTest.php @@ -3,10 +3,13 @@ namespace Biigle\Tests\Http\Controllers\Api; use ApiTestCase; +use Biigle\AnnotationGuideline; +use Biigle\AnnotationGuidelineLabel; use Biigle\Project; use Biigle\Tests\LabelTreeTest; use Biigle\Tests\LabelTreeVersionTest; use Biigle\Visibility; +use Storage; class ProjectLabelTreeControllerTest extends ApiTestCase { @@ -265,4 +268,48 @@ public function testDestroyFormRequest() // should be false because nothing was deleted $response->assertSessionHas('deleted', false); } + + public function testDestroyRequiresForceWithGuidelineLabels() + { + $p = $this->project(); + $t = $this->labelTree(); + $label = $this->labelRoot(); + + $guideline = AnnotationGuideline::factory()->create(['project_id' => $p->id]); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + + $this->beAdmin(); + $this->json('DELETE', "/api/v1/projects/{$p->id}/label-trees/{$t->id}") + ->assertStatus(409); + + $this->assertTrue($p->labelTrees()->where('id', $t->id)->exists()); + } + + public function testDestroyDeletesGuidelineLabels() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $p = $this->project(); + $t = $this->labelTree(); + $label = $this->labelRoot(); + + $guideline = AnnotationGuideline::factory()->create(['project_id' => $p->id]); + $guidelineLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $disk->put("{$guideline->id}/{$guidelineLabel->uuid}", 'content'); + $guidelineLabel->update(['reference_image_path' => "{$guideline->id}/{$guidelineLabel->uuid}"]); + + $this->beAdmin(); + $this->json('DELETE', "/api/v1/projects/{$p->id}/label-trees/{$t->id}", ['force' => true]) + ->assertStatus(200); + + $this->assertNull($guidelineLabel->fresh()); + $disk->assertMissing("{$guideline->id}/{$guidelineLabel->uuid}"); + } } diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php new file mode 100644 index 0000000000..d93414d51c --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php @@ -0,0 +1,446 @@ +project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->doTestApiRoute('GET', $path); + + $this->beUser(); + $this->get($path)->assertStatus(403); + + $this->beGuest(); + $this->get($path)->assertStatus(404); + } + + public function testIndex() + { + $id = $this->project()->id; + $guideline = AnnotationGuideline::factory()->create(['project_id' => $id]); + $label = Label::factory()->create(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + 'shape_id' => Shape::polygonId(), + 'description' => 'labelDescription', + ]); + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beGuest(); + $this->get($path) + ->assertStatus(200) + ->assertJson([ + 'labels' => [[ + 'id' => $label->id, + 'pivot' => [ + 'shape_id' => Shape::polygonId(), + 'description' => 'labelDescription', + ], + ]], + ]); + } + + public function testStoreRequiresAdmin() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->doTestApiRoute('POST', $path); + + $this->beEditor(); + $this->json('POST', $path, ['description' => 'someDescription'])->assertStatus(403); + + $this->beAdmin(); + $this->json('POST', $path, ['description' => 'someDescription'])->assertStatus(201); + } + + public function testStore() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'description' => 'someDescription', + 'enforced' => true, + 'only_shapes' => [Shape::polygonId(), Shape::circleId()], + ])->assertStatus(201); + + $guideline = $this->project()->annotationGuideline; + $this->assertSame($id, $guideline->project_id); + $this->assertSame('someDescription', $guideline->description); + $this->assertTrue($guideline->enforced); + $this->assertEquals([Shape::polygonId(), Shape::circleId()], $guideline->only_shapes); + } + + public function testStoreCastsOnlyShapesToInt() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'enforced' => true, + 'only_shapes' => [(string) Shape::polygonId()], + ])->assertStatus(201); + + $guideline = $this->project()->annotationGuideline; + $this->assertSame([Shape::polygonId()], $guideline->only_shapes); + } + + public function testStoreDefaultsEnforcedToFalse() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [])->assertStatus(201); + + $guideline = $this->project()->annotationGuideline; + $this->assertFalse($guideline->enforced); + $this->assertNull($guideline->only_shapes); + } + + public function testStoreValidatesOnlyShapesUnique() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'enforced' => true, + 'only_shapes' => [Shape::polygonId(), Shape::polygonId()], + ])->assertStatus(422); + } + + public function testStoreValidatesOnlyShapesExist() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'enforced' => true, + 'only_shapes' => [-1], + ])->assertStatus(422); + } + + public function testStoreValidatesOnlyShapesRequiresEnforced() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'only_shapes' => [Shape::polygonId()], + ])->assertStatus(422); + + $this->json('POST', $path, [ + 'enforced' => false, + 'only_shapes' => [Shape::polygonId()], + ])->assertStatus(422); + } + + public function testStoreFailsIfGuidelineExists() + { + $id = $this->project()->id; + AnnotationGuideline::factory()->create(['project_id' => $id]); + $path = "/api/v1/projects/{$id}/annotation-guidelines"; + + $this->beAdmin(); + $this->json('POST', $path, ['description' => 'someDescription'])->assertStatus(422); + } + + public function testUpdateRequiresAdmin() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->doTestApiRoute('PUT', $path); + + $this->beEditor(); + $this->json('PUT', $path, ['description' => 'new'])->assertStatus(403); + + $this->beAdmin(); + $this->json('PUT', $path, ['description' => 'new'])->assertStatus(200); + } + + public function testUpdate() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'description' => 'old', + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, ['description' => 'new'])->assertStatus(200); + $this->assertSame('new', $guideline->fresh()->description); + + $this->json('PUT', $path, ['description' => null])->assertStatus(200); + $this->assertNull($guideline->fresh()->description); + } + + public function testUpdateClearDescriptionOnMissing() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'description' => 'old', + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path)->assertStatus(200); + $this->assertNull($guideline->fresh()->description); + } + + public function testUpdateEnforcedAndOnlyShapes() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => false, + 'only_shapes' => null, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'enforced' => true, + 'only_shapes' => [Shape::polygonId(), Shape::circleId()], + ])->assertStatus(200); + + $guideline->refresh(); + $this->assertTrue($guideline->enforced); + $this->assertEquals([Shape::polygonId(), Shape::circleId()], $guideline->only_shapes); + } + + public function testUpdateNullOnlyShapesOnMissing() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => true, + 'only_shapes' => [Shape::polygonId()], + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, ['only_shapes' => null])->assertStatus(200); + + $this->assertNull($guideline->fresh()->only_shapes); + } + + public function testUpdateNullOnlyShapesOnEmptyArray() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => true, + 'only_shapes' => [Shape::polygonId()], + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, ['only_shapes' => []])->assertStatus(200); + + $this->assertNull($guideline->fresh()->only_shapes); + } + + public function testUpdateClearEnforcedOnMissing() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => true, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path)->assertStatus(200); + $this->assertFalse($guideline->fresh()->enforced); + } + + public function testUpdateValidatesOnlyShapesUnique() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'enforced' => true, + 'only_shapes' => [Shape::polygonId(), Shape::polygonId()], + ])->assertStatus(422); + } + + public function testUpdateCastsOnlyShapesToInt() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'enforced' => true, + 'only_shapes' => [(string) Shape::polygonId()], + ])->assertStatus(200); + + $this->assertSame([Shape::polygonId()], $guideline->fresh()->only_shapes); + } + + public function testUpdateNullsLabelShapesNotInOnlyShapes() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $keepLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'shape_id' => Shape::polygonId(), + ]); + $clearLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'shape_id' => Shape::circleId(), + ]); + $noShapeLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'shape_id' => null, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'enforced' => true, + 'only_shapes' => [Shape::polygonId()], + ])->assertStatus(200); + + $this->assertSame(Shape::polygonId(), $keepLabel->fresh()->shape_id); + $this->assertNull($clearLabel->fresh()->shape_id); + $this->assertNull($noShapeLabel->fresh()->shape_id); + } + + public function testUpdateDoesNotNullLabelShapesIfOnlyShapesMissing() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'shape_id' => Shape::circleId(), + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, ['description' => 'new'])->assertStatus(200); + + $this->assertSame(Shape::circleId(), $label->fresh()->shape_id); + } + + public function testUpdateDoesNotNullLabelShapesIfOnlyShapesNull() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'only_shapes' => [Shape::polygonId()], + ]); + $label = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'shape_id' => Shape::circleId(), + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, ['only_shapes' => null])->assertStatus(200); + + $this->assertSame(Shape::circleId(), $label->fresh()->shape_id); + } + + public function testUpdateValidatesOnlyShapesExist() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'enforced' => true, + 'only_shapes' => [-1], + ])->assertStatus(422); + } + + public function testUpdateValidatesOnlyShapesRequiresEnforced() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->json('PUT', $path, [ + 'only_shapes' => [Shape::polygonId()], + ])->assertStatus(422); + + $this->json('PUT', $path, [ + 'enforced' => false, + 'only_shapes' => [Shape::polygonId()], + ])->assertStatus(422); + } + + public function testDestroyRequiresAdmin() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->doTestApiRoute('DELETE', $path); + + $this->beEditor(); + $this->delete($path)->assertStatus(403); + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + } + + public function testDestroy() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + + $this->assertNull($guideline->fresh()); + } + + public function testDestroyDeletesStorageDirectory() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $disk->put("{$guideline->id}/reference.png", 'content'); + $path = "/api/v1/annotation-guidelines/{$guideline->id}"; + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + + $disk->assertMissing("{$guideline->id}/reference.png"); + } +} diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php new file mode 100644 index 0000000000..13fb967852 --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php @@ -0,0 +1,329 @@ +create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->doTestApiRoute('POST', $path); + + $this->beEditor(); + $this->json('POST', $path, ['label_id' => $label->id])->assertStatus(403); + + $this->beAdmin(); + $this->json('POST', $path, ['label_id' => $label->id])->assertStatus(201); + } + + public function testStore() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'shape_id' => Shape::polygonId(), + 'description' => 'some description', + ])->assertStatus(201); + + $guidelineLabel = $guideline->labels()->where('label_id', $label->id)->first(); + $this->assertNotNull($guidelineLabel); + $this->assertSame(Shape::polygonId(), $guidelineLabel->pivot->shape_id); + $this->assertSame('some description', $guidelineLabel->pivot->description); + $this->assertNotNull($guidelineLabel->pivot->uuid); + } + + public function testStoreUpdatesExisting() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + 'shape_id' => Shape::polygonId(), + 'description' => 'old description', + ]); + + $this->beAdmin(); + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'shape_id' => Shape::circleId(), + 'description' => 'new description', + ])->assertStatus(200); + + $this->assertSame(1, $guideline->labels()->count()); + $guidelineLabel = $guideline->labels()->where('label_id', $label->id)->first(); + $this->assertSame(Shape::circleId(), $guidelineLabel->pivot->shape_id); + $this->assertSame('new description', $guidelineLabel->pivot->description); + } + + public function testStoreCreatesReferenceImage() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + $file = new UploadedFile( + __DIR__.'/../../../../../files/test-image.jpg', + 'test-image.jpg', + test: true + ); + + $this->beAdmin(); + $this->post($path, [ + 'label_id' => $label->id, + 'reference_image' => $file, + ])->assertStatus(201); + + $guidelineLabel = $guideline->labels()->where('label_id', $label->id)->first()->pivot; + $disk->assertExists("{$guideline->id}/{$guidelineLabel->uuid}.jpg"); + $this->assertSame("{$guideline->id}/{$guidelineLabel->uuid}.jpg", $guidelineLabel->reference_image_path); + } + + public function testStoreUpdatesReferenceImage() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $guidelineLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $disk->put("{$guideline->id}/{$guidelineLabel->uuid}.jpg", 'old content'); + $guidelineLabel->update(['reference_image_path' => "{$guideline->id}/{$guidelineLabel->uuid}.jpg"]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + $file = new UploadedFile( + __DIR__.'/../../../../../files/test-image.jpg', + 'test-image.jpg', + test: true + ); + + $this->beAdmin(); + $this->post($path, [ + 'label_id' => $label->id, + 'reference_image' => $file, + ])->assertStatus(200); + + $this->assertNotEquals('old content', $disk->get("{$guideline->id}/{$guidelineLabel->uuid}.jpg")); + } + + public function testStoreDeletesReferenceImageWhenNull() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $guidelineLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $disk->put("{$guideline->id}/{$guidelineLabel->uuid}", 'content'); + $guidelineLabel->update(['reference_image_path' => "{$guideline->id}/{$guidelineLabel->uuid}"]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'reference_image' => null, + ])->assertStatus(200); + + $disk->assertMissing("{$guideline->id}/{$guidelineLabel->uuid}"); + $this->assertNull($guidelineLabel->fresh()->reference_image_path); + } + + public function testStoreKeepsReferenceImageWhenNotProvided() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $guidelineLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $disk->put("{$guideline->id}/{$guidelineLabel->uuid}", 'content'); + $guidelineLabel->update(['reference_image_path' => "{$guideline->id}/{$guidelineLabel->uuid}"]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, ['label_id' => $label->id])->assertStatus(200); + + $disk->assertExists("{$guideline->id}/{$guidelineLabel->uuid}"); + $this->assertSame("{$guideline->id}/{$guidelineLabel->uuid}", $guidelineLabel->fresh()->reference_image_path); + } + + public function testStoreRequiresLabelBelongsToProject() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + // Label from a label tree not attached to the project. + $label = Label::factory()->create(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, ['label_id' => $label->id])->assertStatus(422); + } + + public function testStoreValidatesShapeInOnlyShapes() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => true, + 'only_shapes' => [Shape::polygonId()], + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'shape_id' => Shape::circleId(), + ])->assertStatus(422); + + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'shape_id' => Shape::polygonId(), + ])->assertStatus(201); + } + + public function testStoreIgnoresOnlyShapesIfNotEnforced() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + 'enforced' => false, + 'only_shapes' => [Shape::polygonId()], + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'shape_id' => Shape::circleId(), + ])->assertStatus(201); + } + + public function testStoreValidatesReferenceImageType() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels"; + + $this->beAdmin(); + $this->post($path, [ + 'label_id' => $label->id, + 'reference_image' => UploadedFile::fake()->create('document.txt'), + ])->assertStatus(302); + + // Only JPEG images are allowed. + $this->json('POST', $path, [ + 'label_id' => $label->id, + 'reference_image' => new UploadedFile( + __DIR__.'/../../../../../files/test-image.png', + 'test-image.png', + test: true + ), + ])->assertStatus(422); + } + + public function testDestroyRequiresAdmin() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels/{$label->id}"; + + $this->doTestApiRoute('DELETE', $path); + + $this->beEditor(); + $this->delete($path)->assertStatus(403); + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + } + + public function testDestroy() + { + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels/{$label->id}"; + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + + $this->assertFalse($guideline->labels()->where('label_id', $label->id)->exists()); + } + + public function testDestroyDeletesReferenceImage() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create([ + 'project_id' => $this->project()->id, + ]); + $label = $this->labelRoot(); + $guidelineLabel = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + $disk->put("{$guideline->id}/{$guidelineLabel->uuid}", 'content'); + $guidelineLabel->update(['reference_image_path' => "{$guideline->id}/{$guidelineLabel->uuid}"]); + $path = "/api/v1/annotation-guidelines/{$guideline->id}/labels/{$label->id}"; + + $this->beAdmin(); + $this->delete($path)->assertStatus(200); + + $disk->assertMissing("{$guideline->id}/{$guidelineLabel->uuid}"); + } +} diff --git a/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php b/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php new file mode 100644 index 0000000000..2f94ea5e86 --- /dev/null +++ b/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php @@ -0,0 +1,43 @@ +project()->id; + + $path = "projects/{$id}/annotation-guideline"; + + $this->beGuest(); + $this->get($path)->assertStatus(404); + + $this->beEditor(); + $this->get($path)->assertStatus(404); + + //Admins can create annotation guidelines + $this->beAdmin(); + $this->get($path)->assertStatus(200); + + $this->beGlobalAdmin(); + $this->get($path)->assertStatus(200); + + AnnotationGuideline::factory()->create(['project_id' => $id, 'description' => 'someDescription']); + + $this->beGuest(); + $this->get($path)->assertStatus(200); + + $this->beEditor(); + $this->get($path)->assertStatus(200); + + $this->beAdmin(); + $this->get($path)->assertStatus(200); + + $this->beGlobalAdmin(); + $this->get($path)->assertStatus(200); + + } +} diff --git a/tests/php/LabelTest.php b/tests/php/LabelTest.php index 1546fe9dad..423b473c5f 100644 --- a/tests/php/LabelTest.php +++ b/tests/php/LabelTest.php @@ -2,9 +2,12 @@ namespace Biigle\Tests; +use Biigle\AnnotationGuideline; +use Biigle\AnnotationGuidelineLabel; use Biigle\Label; use Illuminate\Database\QueryException; use ModelTestCase; +use Storage; class LabelTest extends ModelTestCase { @@ -161,4 +164,22 @@ public function testUsedScopeVideoLabel() VideoLabelTest::create(['label_id' => $this->model->id]); $this->assertTrue(Label::used()->exists()); } + + public function testDeleteLabelDeletesGuidelineLabelReferenceImage() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory()->create(); + $pivot = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $this->model->id, + ]); + Storage::disk('annotation_storage')->put("{$guideline->id}/{$pivot->uuid}", 'image'); + $pivot->update(['reference_image_path' => "{$guideline->id}/{$pivot->uuid}"]); + + $this->model->delete(); + + Storage::disk('annotation_storage')->assertMissing("{$guideline->id}/{$pivot->uuid}"); + } } diff --git a/tests/php/LabelTreeTest.php b/tests/php/LabelTreeTest.php index d5ad221ab1..028b8bf8ac 100644 --- a/tests/php/LabelTreeTest.php +++ b/tests/php/LabelTreeTest.php @@ -2,11 +2,14 @@ namespace Biigle\Tests; +use Biigle\AnnotationGuideline; +use Biigle\AnnotationGuidelineLabel; use Biigle\LabelTree; use Biigle\Role; use Biigle\Visibility; use Illuminate\Database\QueryException; use ModelTestCase; +use Storage; class LabelTreeTest extends ModelTestCase { @@ -327,6 +330,25 @@ public function testGetVersionedNameAttribute() $this->assertSame('versioned tree @ v1.0', $this->model->versionedName); } + public function testDeleteLabelTreeDeletesGuidelineLabelReferenceImages() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + Storage::fake('annotation_storage'); + + $label = LabelTest::create(['label_tree_id' => $this->model->id]); + $guideline = AnnotationGuideline::factory()->create(); + $pivot = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => $label->id, + ]); + Storage::disk('annotation_storage')->put("{$guideline->id}/{$pivot->uuid}", 'image'); + $pivot->update(['reference_image_path' => "{$guideline->id}/{$pivot->uuid}"]); + + $this->model->delete(); + + Storage::disk('annotation_storage')->assertMissing("{$guideline->id}/{$pivot->uuid}"); + } + public function testReplicateLabelsOf() { $parent = LabelTest::create(['label_tree_id' => $this->model->id]); diff --git a/tests/php/Policies/AnnotationGuidelinePolicyTest.php b/tests/php/Policies/AnnotationGuidelinePolicyTest.php new file mode 100644 index 0000000000..a98bcfb954 --- /dev/null +++ b/tests/php/Policies/AnnotationGuidelinePolicyTest.php @@ -0,0 +1,40 @@ +project = ProjectTest::create(); + $this->guideline = AnnotationGuidelineTest::create(['project_id' => $this->project->id]); + $this->user = UserTest::create(); + $this->guest = UserTest::create(); + $this->editor = UserTest::create(); + $this->expert = UserTest::create(); + $this->admin = UserTest::create(); + $this->globalAdmin = UserTest::create(['role_id' => Role::adminId()]); + + $this->project->addUserId($this->guest->id, Role::guestId()); + $this->project->addUserId($this->editor->id, Role::editorId()); + $this->project->addUserId($this->expert->id, Role::expertId()); + $this->project->addUserId($this->admin->id, Role::adminId()); + } + + public function testUpdate() + { + $this->assertFalse($this->user->can('update', $this->guideline)); + $this->assertFalse($this->guest->can('update', $this->guideline)); + $this->assertFalse($this->editor->can('update', $this->guideline)); + $this->assertFalse($this->expert->can('update', $this->guideline)); + $this->assertTrue($this->admin->can('update', $this->guideline)); + $this->assertTrue($this->globalAdmin->can('update', $this->guideline)); + } +} diff --git a/tests/php/ProjectTest.php b/tests/php/ProjectTest.php index 4abf01f87e..a7365e1895 100644 --- a/tests/php/ProjectTest.php +++ b/tests/php/ProjectTest.php @@ -2,6 +2,8 @@ namespace Biigle\Tests; +use Biigle\AnnotationGuideline; +use Biigle\AnnotationGuidelineLabel; use Biigle\Jobs\DeleteVolume; use Biigle\MediaType; use Biigle\PendingVolume; @@ -11,6 +13,7 @@ use Illuminate\Database\QueryException; use ModelTestCase; use Queue; +use Storage; use Symfony\Component\HttpKernel\Exception\HttpException; class ProjectTest extends ModelTestCase @@ -347,4 +350,28 @@ public function testPendingVolumes() PendingVolume::factory(['project_id' => $this->model->id])->create(); $this->assertTrue($this->model->pendingVolumes()->exists()); } + + public function testAnnotationGuideline() + { + $this->assertFalse($this->model->annotationGuideline()->exists()); + AnnotationGuideline::factory(['project_id' => $this->model->id])->create(); + $this->assertTrue($this->model->annotationGuideline()->exists()); + } + + public function testDeleteProjectDeletesGuidelineLabelReferenceImages() + { + config(['projects.annotation_guideline_disk' => 'annotation_storage']); + Storage::fake('annotation_storage'); + + $guideline = AnnotationGuideline::factory(['project_id' => $this->model->id])->create(); + $pivot = AnnotationGuidelineLabel::factory()->create([ + 'annotation_guideline_id' => $guideline->id, + 'label_id' => LabelTest::create()->id, + ]); + Storage::disk('annotation_storage')->put("{$guideline->id}/{$pivot->uuid}", 'image'); + + $this->model->delete(); + + Storage::disk('annotation_storage')->assertMissing("{$guideline->id}/{$pivot->uuid}"); + } }