From 4ddad3e978f222f074ce3dac28573357bb6dafc6 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:42:40 +0100 Subject: [PATCH 01/93] Introduce annotationStrategyTab --- .../projects/annotationStrategyContainer.vue | 16 + .../components/annotationStrategy.vue | 158 ++++++++ .../components/annotationStrategyEditor.vue | 347 ++++++++++++++++++ resources/assets/sass/projects/_show.scss | 6 + resources/assets/sass/projects/main.scss | 17 + .../show/annotation-strategy.blade.php | 22 ++ resources/views/projects/show/tabs.blade.php | 7 + routes/web.php | 10 + 8 files changed, 583 insertions(+) create mode 100644 resources/assets/js/projects/annotationStrategyContainer.vue create mode 100644 resources/assets/js/projects/components/annotationStrategy.vue create mode 100644 resources/assets/js/projects/components/annotationStrategyEditor.vue create mode 100644 resources/views/projects/show/annotation-strategy.blade.php diff --git a/resources/assets/js/projects/annotationStrategyContainer.vue b/resources/assets/js/projects/annotationStrategyContainer.vue new file mode 100644 index 000000000..e2d780c62 --- /dev/null +++ b/resources/assets/js/projects/annotationStrategyContainer.vue @@ -0,0 +1,16 @@ + diff --git a/resources/assets/js/projects/components/annotationStrategy.vue b/resources/assets/js/projects/components/annotationStrategy.vue new file mode 100644 index 000000000..bad52d2b4 --- /dev/null +++ b/resources/assets/js/projects/components/annotationStrategy.vue @@ -0,0 +1,158 @@ + + diff --git a/resources/assets/js/projects/components/annotationStrategyEditor.vue b/resources/assets/js/projects/components/annotationStrategyEditor.vue new file mode 100644 index 000000000..913086c81 --- /dev/null +++ b/resources/assets/js/projects/components/annotationStrategyEditor.vue @@ -0,0 +1,347 @@ + + + diff --git a/resources/assets/sass/projects/_show.scss b/resources/assets/sass/projects/_show.scss index d81af7d0b..52daa4233 100644 --- a/resources/assets/sass/projects/_show.scss +++ b/resources/assets/sass/projects/_show.scss @@ -114,3 +114,9 @@ } } } + +.annotation-strategy-label-options { + height: 250px; + padding: 5px; + border-style: outset; +} diff --git a/resources/assets/sass/projects/main.scss b/resources/assets/sass/projects/main.scss index 53ee3535b..21186eed6 100644 --- a/resources/assets/sass/projects/main.scss +++ b/resources/assets/sass/projects/main.scss @@ -22,6 +22,23 @@ margin-top: 5em; } +.form-control.annotation-strategy-description { + height: 200px; + box-sizing: border-box; + text-align: left; + align-content: top; +} + +.strategy-description { + width: 100%; + resize: none; + overflow: hidden; /* Hides the scrollbar */ + min-height: 50px; + padding: 10px; + box-sizing: border-box; + color: black; +} + @import 'components/previewThumbnail'; @import 'show'; @import 'charts'; diff --git a/resources/views/projects/show/annotation-strategy.blade.php b/resources/views/projects/show/annotation-strategy.blade.php new file mode 100644 index 000000000..1e710afe2 --- /dev/null +++ b/resources/views/projects/show/annotation-strategy.blade.php @@ -0,0 +1,22 @@ +@extends('projects.show.base') + +@push('scripts') + +@endpush + +//todo: convert annotationStrategy +@section('project-content') +
+
+
+ +
+
+
+@endsection diff --git a/resources/views/projects/show/tabs.blade.php b/resources/views/projects/show/tabs.blade.php index 25878f1eb..690d3cd4e 100644 --- a/resources/views/projects/show/tabs.blade.php +++ b/resources/views/projects/show/tabs.blade.php @@ -22,5 +22,12 @@ @endif + //TODO: this should be fixed + @if (($user->can('edit-in', $project) || $user->can('sudo'))) +
  • + Strategy +
  • + @endif + @mixin('projectsShowTabs') diff --git a/routes/web.php b/routes/web.php index 76db0eb0e..f091e6942 100644 --- a/routes/web.php +++ b/routes/web.php @@ -280,6 +280,16 @@ 'as' => 'projectsLargo', 'uses' => 'LargoController@index', ]); + + $router->get('{id}/charts', [ + 'as' => 'project-charts', + 'uses' => 'ProjectStatisticsController@show', + ]); + + $router->get('{id}/annotation-strategy', [ + 'as' => 'annotation-strategy', + 'uses' => 'AnnotationStrategyController@show', + ]); }); $router->group(['namespace' => 'Volumes', 'prefix' => 'pending-volumes'], function ($router) { From 69d15545caa5fefc1f28ee18ae794bf70567f28c Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:43:16 +0100 Subject: [PATCH 02/93] Introduces AnnotationStrategyController classes --- .../Projects/AnnotationStrategyController.php | 88 +++++++++++++ .../AnnotationStrategyLabelController.php | 116 ++++++++++++++++++ .../Projects/AnnotationStrategyController.php | 91 ++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 app/Http/Controllers/Api/Projects/AnnotationStrategyController.php create mode 100644 app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php create mode 100644 app/Http/Controllers/Views/Projects/AnnotationStrategyController.php diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php new file mode 100644 index 000000000..fedb62940 --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php @@ -0,0 +1,88 @@ + $id])->firstOrFail(); + $this->authorize('access', $project); + return AnnotationStrategy::first('project', $id); + } + + //TODO: form request for strategy + /** + * Update a label. + * + * @api {put} labels/:id Update a label + * @apiGroup Labels + * @apiName UpdateLabels + * @apiPermission labelTreeEditor + * + * @apiParam {Number} id The label ID + * + * @apiParam (Attributes that can be updated) {String} name Name of the label. + * @apiParam (Attributes that can be updated) {String} color Color of the label as hexadecimal string (like `bada55`). May have an optional `#` prefix. + * @apiParam (Attributes that can be updated) {Number} parent_id ID of the parent label for ordering in a tree-like structure. + * + * @param UpdateLabel $request + */ + //public function update(UpdateAnnotationStrategy $request) + public function update(Request $request) + { + AnnotationStrategy::updateOrCreate( + ['project' => $request->id], + ['description' => $request->description] + ); + } + + /** + * Delete a label. + * + * @api {delete} labels/:id Delete a label + * @apiGroup Labels + * @apiName DestroyLabels + * @apiPermission labelTreeEditor + * @apiDescription A label may only be deleted if it doesn't have child labels and is + * not in use anywhere (e.g. attached to an annotation). + * + * @apiParam {Number} id The label ID + * + * @param DestroyLabel $request + */ + //public function destroy(DestroyAnnotationStrategy $request) + public function delete(Request $request) + { + $annotationStrategy = AnnotationStrategy::where(['project'=> $request->id])->firstOrFail(); + $annotationStrategy->delete(); + } + +} diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php new file mode 100644 index 000000000..29ec26cff --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php @@ -0,0 +1,116 @@ +id); + $this->authorize('access', $project); + $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); + //TODO: add method for annotationStrategyLabels that returns the names rather than the ids of shape, labels etc + return $annotationStrategy->strategyLabels()->get(); + } + + //TODO: form request for strategy + /** + * Upload the reference image for a label. + * + * @api {put} labels/:id Update a label + * @apiGroup Labels + * @apiName UpdateLabels + * @apiPermission labelTreeEditor + * + * @apiParam {Number} id The label ID + * + * @apiParam (Attributes that can be updated) {String} name Name of the label. + * @apiParam (Attributes that can be updated) {String} color Color of the label as hexadecimal string (like `bada55`). May have an optional `#` prefix. + * @apiParam (Attributes that can be updated) {Number} parent_id ID of the parent label for ordering in a tree-like structure. + * + * @param UpdateLabel $request + */ + //public function update(UpdateAnnotationStrategy $request) + public function update(Request $request) + { + $project = Project::findOrFail($request->id); + $this->authorize('access', $project); + + $labels = $request->labels; + $shapes = $request->shapes; + $descriptions = $request->descriptions; + + $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); + $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels)->delete(); + + for ($i = 0; $i < count($labels); $i++) { + AnnotationStrategyLabel::updateOrCreate( + [ + 'annotation_strategy_id' => $annotationStrategy->id, + 'label_id' => $labels[$i], + ], + [ + 'shape_id' => $shapes[$i], + 'description' => $descriptions[$i], + ] + ); + } + } + + public function storeReferenceImage(Request $request) + { + //TODO: validate request using + $project = Project::findOrFail($request->id); + $this->authorize('access', $project); + $request->validate([ + 'file' => 'required|file|mimes:jpg,png,pdf|max:5120', + ]); + + $file = $request->file; + $shapes = $request->shapes; + $descriptions = $request->descriptions; + + $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); + $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels)->delete(); + + for ($i = 0; $i < count($labels); $i++) { + AnnotationStrategyLabel::updateOrCreate( + [ + 'annotation_strategy_id' => $annotationStrategy->id, + 'label_id' => $labels[$i], + ], + [ + 'shape_id' => $shapes[$i], + 'description' => $descriptions[$i], + ] + ); + } + } +} diff --git a/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php new file mode 100644 index 000000000..787e150ca --- /dev/null +++ b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php @@ -0,0 +1,91 @@ +user(); + + if (!$user->can('sudo')) { + $this->authorize('access', $project); + } + + $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(); + + $annotationStrategy = AnnotationStrategy::where(['project' => $id])->first(); + $isAdmin = $user->role_id === Role::adminId() || !$user->can('sudo'); + + $labelTrees = $project->labelTrees() + ->select('id', 'name', 'version_id') + ->with('labels', 'version') + ->get(); + + $shapes = Shape::pluck('name', 'id'); + + if (!$annotationStrategy) { + if ($isAdmin) { + //TODO: here we should return create strategy version of the page + return view('projects.show.annotation-strategy', [ + "project" => $project, + 'user' => $user, + "annotationStrategy" => null, + "annotationStrategyLabels" => null, + 'isMember' => $isMember, + 'isAdmin' => $isAdmin, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'strategy', + 'labelTrees' => $labelTrees, + 'availableShapes' => $shapes, + ]); + } + abort(Response::HTTP_NOT_FOUND); + } + + $annotationStrategyLabels = $annotationStrategy->strategyLabels()->with('label')->get(); + + //dd($annotationStrategyLabels[0]->label); + + return view('projects.show.annotation-strategy', [ + "project" => $project, + 'annotationStrategy' => $annotationStrategy->toArray(), + "annotationStrategyLabels" => $annotationStrategyLabels, + 'user' => $user, + 'isMember' => $isMember, + 'isAdmin' => $isAdmin, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'strategy', + 'labelTrees' => $labelTrees, + 'availableShapes' => $shapes, + ]); + } +} From 35c4693bb00399ef8e67e23e395d38fe55df2d99 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:43:51 +0100 Subject: [PATCH 03/93] Introduce annotation_strategies and annotation_strategy_labels tables --- ...3908_create_annotation_strategy_tables.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php diff --git a/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php new file mode 100644 index 000000000..f28296c03 --- /dev/null +++ b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('project') + ->constrained() + ->onDelete('cascade'); + $table->text('description') + }); + + Schema::create('annotation_strategy_labels', function (Blueprint $table) { + $table->foreignId('annotation_strategy_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('label_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('shape_id') + ->nullable(true) + ->constrained() + ->onDelete('set null'); + + $table->text('description'); + + $table->string('reference_image') + ->nullable(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('annotation_strategy_label'); + Schema::dropIfExists('annotation_strategy'); + } +}; From 3eee3440a0db72a11280feff2be8bbd3d922cee0 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:44:19 +0100 Subject: [PATCH 04/93] Introduce AnnotationStrategy and AnnotationStrategyLabel classes --- app/AnnotationStrategy.php | 56 +++++++++++++++++++++++++++++++++ app/AnnotationStrategyLabel.php | 51 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 app/AnnotationStrategy.php create mode 100644 app/AnnotationStrategyLabel.php diff --git a/app/AnnotationStrategy.php b/app/AnnotationStrategy.php new file mode 100644 index 000000000..7ae43378b --- /dev/null +++ b/app/AnnotationStrategy.php @@ -0,0 +1,56 @@ + + */ + protected $casts = [ + 'id' => 'int', + 'project' => 'int', + 'description' => 'string', + ]; + + protected $fillable = [ + 'project', + 'description', + ]; + + /** + * Don't maintain timestamps for this model. + * + * @var bool + */ + public $timestamps = false; + + /** + * The project this strategy belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function project() + { + return $this->belongsTo(Project::class, 'project'); + } + + public function strategyLabels() + { + return $this->hasMany(AnnotationStrategyLabel::class); + } +} diff --git a/app/AnnotationStrategyLabel.php b/app/AnnotationStrategyLabel.php new file mode 100644 index 000000000..56253a856 --- /dev/null +++ b/app/AnnotationStrategyLabel.php @@ -0,0 +1,51 @@ + + */ + protected $casts = [ + 'annotation_strategy_id' => 'int', + 'label_id' => 'int', + 'shape_id' => 'int', + 'description' => 'string', + ]; + //TODO: add comment + + protected $fillable = [ + 'annotation_strategy_id', + 'label_id', + 'shape_id', + 'description', + ]; + /** + * Don't maintain timestamps for this model. + * + * @var bool + */ + public $timestamps = false; + + public function label() + { + return $this->belongsTo(Label::class, foreignKey: 'label_id'); + } +} + From 7085f7d60c4c440ba14b209f4ee367e6923d2311 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:44:53 +0100 Subject: [PATCH 05/93] Introduce annotationStrategy annotationStrategyLabel resources --- .../assets/js/projects/api/annotationStrategy.js | 12 ++++++++++++ .../js/projects/api/annotationStrategyLabel.js | 15 +++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 resources/assets/js/projects/api/annotationStrategy.js create mode 100644 resources/assets/js/projects/api/annotationStrategyLabel.js diff --git a/resources/assets/js/projects/api/annotationStrategy.js b/resources/assets/js/projects/api/annotationStrategy.js new file mode 100644 index 000000000..a1152e5e9 --- /dev/null +++ b/resources/assets/js/projects/api/annotationStrategy.js @@ -0,0 +1,12 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for project strategies. + * + * Create or update a strategy. + * resource.save({id: projectId, description: description}, {...}).then(...); + * + * Delete a strategy. + * resource.delete({id: projectId}).then(...); + */ +export default Resource('api/v1/projects{/id}/annotation-strategy') diff --git a/resources/assets/js/projects/api/annotationStrategyLabel.js b/resources/assets/js/projects/api/annotationStrategyLabel.js new file mode 100644 index 000000000..1839b0122 --- /dev/null +++ b/resources/assets/js/projects/api/annotationStrategyLabel.js @@ -0,0 +1,15 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for the labels within annoation strategies. + * + * Create or update a label in a strategy. + * resource.save({id: projectId, descriptions: [description], labels: [labels]}, {...}).then(...); + * + */ +export default Resource('api/v1/projects{/id}/annotation-strategy-label',{}, { + upload_file: { + method: 'POST', + url: 'api/v1/projects{/id}/annotation-strategy-label/upload-image', + } +}) From ba65e06ed1cb27ff1621d92ed5abfde9db856207 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:45:14 +0100 Subject: [PATCH 06/93] Introduce annotationStrategy to main.js --- resources/assets/js/projects/main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/assets/js/projects/main.js b/resources/assets/js/projects/main.js index de9ff7116..24044d8b7 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 AnnotationStrategyContainer from './annotationStrategyContainer.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-strategy-container', AnnotationStrategyContainer); From e8067e11107ab6fda54f82838eb247fd71f7ce77 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Tue, 10 Mar 2026 09:45:28 +0100 Subject: [PATCH 07/93] Adds api routes --- routes/api.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/routes/api.php b/routes/api.php index 626a70cae..107becec2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -284,6 +284,27 @@ 'uses' => 'Projects\ProjectAnnotationLabels@getProjectAnnotationLabelCounts', ]); +$router->get('projects/{id}/annotation-strategy', [ + 'uses' => 'Projects\AnnotationStrategyController@index', +]); + +$router->post('projects/{id}/annotation-strategy', [ + 'uses' => 'Projects\AnnotationStrategyController@update', +]); + +$router->delete('projects/{id}/annotation-strategy', [ + 'uses' => 'Projects\AnnotationStrategyController@delete', +]); + +$router->post('projects/{id}/annotation-strategy-label', [ + 'uses' => 'Projects\AnnotationStrategyLabelController@update', +]); + +$router->post('projects/{id}/annotation-strategy-label/upload-image', [ + 'uses' => 'Projects\AnnotationStrategyLabelController@storeReferenceImage', +]); + + $router->get('public-export/label-trees/{id}', [ 'as' => 'get-public-label-tree-export', 'uses' => 'Export\PublicLabelTreeExportController@show', From 6919f86206e02c285cce08fef64b7ce06f79acd4 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:56:56 +0100 Subject: [PATCH 08/93] Adds the annotationStrategyLabelImage component --- .../annotationStrategyLabelImage.vue | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 resources/assets/js/projects/components/annotationStrategyLabelImage.vue diff --git a/resources/assets/js/projects/components/annotationStrategyLabelImage.vue b/resources/assets/js/projects/components/annotationStrategyLabelImage.vue new file mode 100644 index 000000000..bd4c37b1d --- /dev/null +++ b/resources/assets/js/projects/components/annotationStrategyLabelImage.vue @@ -0,0 +1,107 @@ + + From 1efbf56b1386559448571526fbcf1caa06ecc51e Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:57:23 +0100 Subject: [PATCH 09/93] Finalize the annotationStrategycomponents --- .../components/annotationStrategy.vue | 103 +++--- .../components/annotationStrategyEditor.vue | 323 +++++++++++------- 2 files changed, 240 insertions(+), 186 deletions(-) diff --git a/resources/assets/js/projects/components/annotationStrategy.vue b/resources/assets/js/projects/components/annotationStrategy.vue index bad52d2b4..ddad1dd10 100644 --- a/resources/assets/js/projects/components/annotationStrategy.vue +++ b/resources/assets/js/projects/components/annotationStrategy.vue @@ -9,33 +9,38 @@ -

    Description

    -
    -

    {{ strategyDescription }}

    +
    +

    {{ annotationStrategy.description }}

    -
    +

    Label

    +
    +

    Label description

    +

    Shape

    -
    +

    Reference Image

    -
    -

    Label description

    -
    +
    -
    - {{ mapShape(annotationStrategyLabel.shape) }} -
    - exampleimage + {{ annotationStrategyLabel.description }} +
    +
    + + {{ mapShape(annotationStrategyLabel.shape_id) }}
    - {{ annotationStrategyLabel.description }} +
    @@ -61,28 +71,20 @@ - From c1a5748b1b0ae61ea4d3e7a26b6272e4ce5a649c Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:58:02 +0100 Subject: [PATCH 10/93] Adds methods to upload and delete reference images --- resources/assets/js/projects/api/annotationStrategyLabel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/assets/js/projects/api/annotationStrategyLabel.js b/resources/assets/js/projects/api/annotationStrategyLabel.js index 1839b0122..11edd235d 100644 --- a/resources/assets/js/projects/api/annotationStrategyLabel.js +++ b/resources/assets/js/projects/api/annotationStrategyLabel.js @@ -11,5 +11,9 @@ export default Resource('api/v1/projects{/id}/annotation-strategy-label',{}, { upload_file: { method: 'POST', url: 'api/v1/projects{/id}/annotation-strategy-label/upload-image', + }, + delete_file: { + method: 'DELETE', + url: 'api/v1/projects{/id}/annotation-strategy-label/delete-image', } }) From 5db3bbbce49f2bdc877e72d50b4146810a000c50 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:58:16 +0100 Subject: [PATCH 11/93] Fix migration --- .../2026_03_02_103908_create_annotation_strategy_tables.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php index f28296c03..2009a9a68 100644 --- a/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php +++ b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php @@ -16,7 +16,7 @@ public function up(): void $table->foreignId('project') ->constrained() ->onDelete('cascade'); - $table->text('description') + $table->text('description'); }); Schema::create('annotation_strategy_labels', function (Blueprint $table) { @@ -37,6 +37,7 @@ public function up(): void $table->string('reference_image') ->nullable(true); + $table->primary(['annotation_strategy_id', 'label_id']); }); } From 9031cdc9e244ffc2eb805135851fa38466a3d383 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:58:51 +0100 Subject: [PATCH 12/93] Adds configurations for annotation strategies --- config/annotation_strategy.php | 5 +++++ config/filesystems.php | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 config/annotation_strategy.php diff --git a/config/annotation_strategy.php b/config/annotation_strategy.php new file mode 100644 index 000000000..561e14340 --- /dev/null +++ b/config/annotation_strategy.php @@ -0,0 +1,5 @@ + env('ANNOTATION_STRATEGY_STORAGE_DISK', 'annotation-strategy'), +]; diff --git a/config/filesystems.php b/config/filesystems.php index 6b5ba1901..5070960a8 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')), - +'default' => env('FILESYSTEM_DISK', env('FILESYSTEM_DRIVER', 'local')), /* |-------------------------------------------------------------------------- | Filesystem Disks @@ -95,6 +93,13 @@ 'url' => env('APP_URL').'/storage/largo-patches', 'visibility' => 'public', ], + + 'annotation-strategy' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/annotation-strategy-reference-images'), + 'url' => env('APP_URL').'/storage/annotation-strategy-reference-images', + 'visibility' => 'public', + ], ], /* From 92f9e9245b7bb430a3578e95d8a5617bd912b754 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 13:59:11 +0100 Subject: [PATCH 13/93] Fix annotationStrategyLabel model --- app/AnnotationStrategyLabel.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/AnnotationStrategyLabel.php b/app/AnnotationStrategyLabel.php index 56253a856..0a2561c04 100644 --- a/app/AnnotationStrategyLabel.php +++ b/app/AnnotationStrategyLabel.php @@ -15,7 +15,7 @@ class AnnotationStrategyLabel extends Model { use HasFactory; public $incrementing = false; - protected $primaryKey = null; + protected $primaryKey = ['annotation_strategy_id', 'label_id']; /** * The attributes that should be casted to native types. @@ -27,6 +27,7 @@ class AnnotationStrategyLabel extends Model 'label_id' => 'int', 'shape_id' => 'int', 'description' => 'string', + 'reference_image' => 'string', ]; //TODO: add comment @@ -35,6 +36,7 @@ class AnnotationStrategyLabel extends Model 'label_id', 'shape_id', 'description', + 'reference_image', ]; /** * Don't maintain timestamps for this model. @@ -47,5 +49,14 @@ public function label() { return $this->belongsTo(Label::class, foreignKey: 'label_id'); } + + protected function setKeysForSaveQuery($query) + { + foreach ($this->getKeyName() as $key) { + $query->where($key, '=', $this->getAttribute($key)); + } + return $query; + } + } From 2c47384fd09d8ab26ea81be9ae47be9456822bc5 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:00:54 +0100 Subject: [PATCH 14/93] Fix AnnotationStrategyController --- .../Views/Projects/AnnotationStrategyController.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php index 787e150ca..7bac1d3f7 100644 --- a/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php +++ b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php @@ -11,6 +11,7 @@ use Biigle\User; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Js; class AnnotationStrategyController extends Controller { @@ -41,6 +42,7 @@ public function show(Request $request, int $id) ->count(); $annotationStrategy = AnnotationStrategy::where(['project' => $id])->first(); + $isAdmin = $user->role_id === Role::adminId() || !$user->can('sudo'); $labelTrees = $project->labelTrees() @@ -52,12 +54,11 @@ public function show(Request $request, int $id) if (!$annotationStrategy) { if ($isAdmin) { - //TODO: here we should return create strategy version of the page return view('projects.show.annotation-strategy', [ "project" => $project, 'user' => $user, - "annotationStrategy" => null, - "annotationStrategyLabels" => null, + "annotationStrategy" => [], + "annotationStrategyLabels" => [], 'isMember' => $isMember, 'isAdmin' => $isAdmin, 'isPinned' => $isPinned, @@ -72,8 +73,6 @@ public function show(Request $request, int $id) $annotationStrategyLabels = $annotationStrategy->strategyLabels()->with('label')->get(); - //dd($annotationStrategyLabels[0]->label); - return view('projects.show.annotation-strategy', [ "project" => $project, 'annotationStrategy' => $annotationStrategy->toArray(), From baeacd25ed6c03c183dc51fe9f95b1677f9a26af Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:01:28 +0100 Subject: [PATCH 15/93] Fix AnnotationStrategy Apis --- .../Projects/AnnotationStrategyController.php | 10 ++- .../AnnotationStrategyLabelController.php | 62 ++++++++++++------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php index fedb62940..75e5b26c1 100644 --- a/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php @@ -6,6 +6,7 @@ use Biigle\Label; use Biigle\Project; use Biigle\AnnotationStrategy; +use Biigle\AnnotationStrategyLabel; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -33,9 +34,13 @@ class AnnotationStrategyController extends Controller */ public function index($id) { - $project = Project::where(['project' => $id])->firstOrFail(); + $project = Project::findOrFail($id); $this->authorize('access', $project); - return AnnotationStrategy::first('project', $id); + $strategy = AnnotationStrategy::where(['project'=> $id]) + ->firstOrFail(); + $strategyLabels = $strategy->strategyLabels()->with('label')->get(); + return ['annotation_strategy' => $strategy, 'annotation_strategy_labels' => $strategyLabels]; + } //TODO: form request for strategy @@ -81,6 +86,7 @@ public function update(Request $request) //public function destroy(DestroyAnnotationStrategy $request) public function delete(Request $request) { + //TODO: cleanup all files in the strategy labels $annotationStrategy = AnnotationStrategy::where(['project'=> $request->id])->firstOrFail(); $annotationStrategy->delete(); } diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php index 29ec26cff..d215feedf 100644 --- a/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php @@ -9,6 +9,7 @@ use Biigle\AnnotationStrategyLabel; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Storage; class AnnotationStrategyLabelController extends Controller { /** @@ -34,9 +35,8 @@ class AnnotationStrategyLabelController extends Controller { public function index(Request $request) { $project = Project::findOrFail($request->id); - $this->authorize('access', $project); + $this->authorize('editIn', $project); $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); - //TODO: add method for annotationStrategyLabels that returns the names rather than the ids of shape, labels etc return $annotationStrategy->strategyLabels()->get(); } @@ -61,14 +61,24 @@ public function index(Request $request) public function update(Request $request) { $project = Project::findOrFail($request->id); - $this->authorize('access', $project); + $this->authorize('update', $project); $labels = $request->labels; $shapes = $request->shapes; $descriptions = $request->descriptions; + $referenceImages = $request->reference_images; $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); - $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels)->delete(); + $aslToDelete = $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels); + $aslToDelete->delete(); + + $disk = Storage::disk(config('annotation_strategy.storage_disk')); + foreach ($aslToDelete as $asl) { + $url = "$project->id/$asl->reference_image"; + if ($disk->exists($url)) { + $disk->delete($url); + } + } for ($i = 0; $i < count($labels); $i++) { AnnotationStrategyLabel::updateOrCreate( @@ -79,38 +89,44 @@ public function update(Request $request) [ 'shape_id' => $shapes[$i], 'description' => $descriptions[$i], + 'reference_image' => $referenceImages[$i], ] - ); + )->toSql(); } } public function storeReferenceImage(Request $request) { - //TODO: validate request using + //TODO: validate request? + //TODO: should we send it to a temporary dir? + //TODO: should we resize the image? $project = Project::findOrFail($request->id); - $this->authorize('access', $project); + $this->authorize('update', $project); $request->validate([ 'file' => 'required|file|mimes:jpg,png,pdf|max:5120', ]); - $file = $request->file; - $shapes = $request->shapes; - $descriptions = $request->descriptions; + $file = $request->file('file'); + $name = $file->hashName(); + $disk = Storage::disk(config('annotation_strategy.storage_disk')); + $disk->putFileAs("$project->id/", $file, $name); + return ['filename' => $name]; + } - $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); - $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels)->delete(); + public function deleteReferenceImage(Request $request) + { + //TODO: validate request? + //TODO: should we send it to a temporary dir? + //TODO: should we resize the image? + $project = Project::findOrFail($request->id); + $name = $request->input('reference_image'); - for ($i = 0; $i < count($labels); $i++) { - AnnotationStrategyLabel::updateOrCreate( - [ - 'annotation_strategy_id' => $annotationStrategy->id, - 'label_id' => $labels[$i], - ], - [ - 'shape_id' => $shapes[$i], - 'description' => $descriptions[$i], - ] - ); + $this->authorize('update', $project); + + $disk = Storage::disk(config('annotation_strategy.storage_disk')); + $url = "$project->id/$name"; + if ($disk->exists($url)) { + $disk->delete($url); } } } From 002569330ed7a700b8a3126df2aaea6d262f8ffd Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:01:47 +0100 Subject: [PATCH 16/93] Adds api routes for uploading images --- routes/api.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routes/api.php b/routes/api.php index 107becec2..7f2b4b974 100644 --- a/routes/api.php +++ b/routes/api.php @@ -304,6 +304,9 @@ 'uses' => 'Projects\AnnotationStrategyLabelController@storeReferenceImage', ]); +$router->delete('projects/{id}/annotation-strategy-label/delete-image', [ + 'uses' => 'Projects\AnnotationStrategyLabelController@deleteReferenceImage', +]); $router->get('public-export/label-trees/{id}', [ 'as' => 'get-public-label-tree-export', From 651ac25c2ffe14efdc2ecc0fa07ea64345811dfe Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:02:04 +0100 Subject: [PATCH 17/93] Remove method --- .../assets/js/projects/annotationStrategyContainer.vue | 7 ------- 1 file changed, 7 deletions(-) diff --git a/resources/assets/js/projects/annotationStrategyContainer.vue b/resources/assets/js/projects/annotationStrategyContainer.vue index e2d780c62..6848d58a5 100644 --- a/resources/assets/js/projects/annotationStrategyContainer.vue +++ b/resources/assets/js/projects/annotationStrategyContainer.vue @@ -5,12 +5,5 @@ export default { components: { annotationStrategy: AnnotationStrategy, }, - method: { - sharedMethod() { - console.log("YAYYY"); - } - - } }; - From f0fdbd63ef430f05f2eb0d9bc5e4b0866200be71 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:02:40 +0100 Subject: [PATCH 18/93] Fix css for annotation strategies to make them better centred --- resources/assets/sass/projects/main.scss | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/resources/assets/sass/projects/main.scss b/resources/assets/sass/projects/main.scss index 21186eed6..c6b256e80 100644 --- a/resources/assets/sass/projects/main.scss +++ b/resources/assets/sass/projects/main.scss @@ -29,16 +29,54 @@ align-content: top; } -.strategy-description { +textarea.strategy-description { width: 100%; resize: none; overflow: hidden; /* Hides the scrollbar */ min-height: 50px; - padding: 10px; + margin: 3px; box-sizing: border-box; color: black; } +.annotation-strategy-label { + display: flex; + align-items: center; + padding-top: 5px; + padding-bottom: 5px; + margin-top: 5px; + margin-bottom: 5px; + border-top: 1px solid white; + border-bottom: 1px solid white; +} + +.annotation-strategy-label-edit { + justify-content: center; + padding: 5px; + margin: 5px; + border-bottom: 1px solid white; +} + +.center-container { + display: flex; + justify-content: center; +} + +.btn-asl { + margin: 3px; +} + +.reference-image { + max-width: 200px; + min-width: 50px; + max-height: 200px; + min-height: 50px; +} + +#strategy-description-text { + white-space: pre-line; +} + @import 'components/previewThumbnail'; @import 'show'; @import 'charts'; From 9cb1775c786cc1cea4f795cc9f2eb1787ec4d96c Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:03:06 +0100 Subject: [PATCH 19/93] Fix blade template for annotation strategies --- resources/views/projects/show/annotation-strategy.blade.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/projects/show/annotation-strategy.blade.php b/resources/views/projects/show/annotation-strategy.blade.php index 1e710afe2..d226212d6 100644 --- a/resources/views/projects/show/annotation-strategy.blade.php +++ b/resources/views/projects/show/annotation-strategy.blade.php @@ -4,17 +4,17 @@ @endpush -//todo: convert annotationStrategy @section('project-content')
    From cf8084833b5f58f689ee89f70ce161aa2cb94844 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Thu, 12 Mar 2026 14:03:42 +0100 Subject: [PATCH 20/93] Fix annotation strategy tab --- resources/views/projects/show/tabs.blade.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/resources/views/projects/show/tabs.blade.php b/resources/views/projects/show/tabs.blade.php index 690d3cd4e..f5b9238e6 100644 --- a/resources/views/projects/show/tabs.blade.php +++ b/resources/views/projects/show/tabs.blade.php @@ -22,10 +22,9 @@ @endif - //TODO: this should be fixed - @if (($user->can('edit-in', $project) || $user->can('sudo'))) + @if ((($user->can('update', $project) || $user->can('sudo'))) || $user->can('editIn') && $annotationStrategy != null)
  • - Strategy + Strategy
  • @endif From 994c6ca56b0962b26b4869615efa74c22c67383d Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Fri, 13 Mar 2026 14:18:01 +0100 Subject: [PATCH 21/93] Rename columns in AnnotationStrategy models, adds docstrings --- app/AnnotationStrategy.php | 7 +++- app/AnnotationStrategyLabel.php | 33 ++++++++++++++----- ...3908_create_annotation_strategy_tables.php | 12 +++---- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/app/AnnotationStrategy.php b/app/AnnotationStrategy.php index 7ae43378b..b3f562469 100644 --- a/app/AnnotationStrategy.php +++ b/app/AnnotationStrategy.php @@ -49,8 +49,13 @@ public function project() return $this->belongsTo(Project::class, 'project'); } + /** + * The strategies for the labels within this strategy. + * + * @return \Illuminate\Database\Eloquent\Relations\hasMany + */ public function strategyLabels() { - return $this->hasMany(AnnotationStrategyLabel::class); + return $this->hasMany(AnnotationStrategyLabel::class, 'annotation_strategy'); } } diff --git a/app/AnnotationStrategyLabel.php b/app/AnnotationStrategyLabel.php index 0a2561c04..6f89ab14f 100644 --- a/app/AnnotationStrategyLabel.php +++ b/app/AnnotationStrategyLabel.php @@ -15,7 +15,7 @@ class AnnotationStrategyLabel extends Model { use HasFactory; public $incrementing = false; - protected $primaryKey = ['annotation_strategy_id', 'label_id']; + protected $primaryKey = ['annotation_strategy', 'label']; /** * The attributes that should be casted to native types. @@ -23,18 +23,22 @@ class AnnotationStrategyLabel extends Model * @var array */ protected $casts = [ - 'annotation_strategy_id' => 'int', - 'label_id' => 'int', - 'shape_id' => 'int', + 'annotation_strategy' => 'int', + 'label' => 'int', + 'shape' => 'int', 'description' => 'string', 'reference_image' => 'string', ]; - //TODO: add comment + /** + * The attributes that are mass assignable. + * + * @var list + */ protected $fillable = [ - 'annotation_strategy_id', - 'label_id', - 'shape_id', + 'annotation_strategy', + 'label', + 'shape', 'description', 'reference_image', ]; @@ -45,11 +49,22 @@ class AnnotationStrategyLabel extends Model */ public $timestamps = false; + + /** + * The labels that have a strategy for their annotation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ public function label() { - return $this->belongsTo(Label::class, foreignKey: 'label_id'); + return $this->belongsTo(Label::class, 'label'); } + /** + * Since this table does not have a primary key but uses two keys as primary keys + * + * @return \Illuminate\Database\Eloquent\Builder + */ protected function setKeysForSaveQuery($query) { foreach ($this->getKeyName() as $key) { diff --git a/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php index 2009a9a68..e6e5bb55a 100644 --- a/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php +++ b/database/migrations/2026_03_02_103908_create_annotation_strategy_tables.php @@ -20,15 +20,15 @@ public function up(): void }); Schema::create('annotation_strategy_labels', function (Blueprint $table) { - $table->foreignId('annotation_strategy_id') + $table->foreignId('annotation_strategy') ->constrained() ->onDelete('cascade'); - $table->foreignId('label_id') + $table->foreignId('label') ->constrained() ->onDelete('cascade'); - $table->foreignId('shape_id') + $table->foreignId('shape') ->nullable(true) ->constrained() ->onDelete('set null'); @@ -37,7 +37,7 @@ public function up(): void $table->string('reference_image') ->nullable(true); - $table->primary(['annotation_strategy_id', 'label_id']); + $table->primary(['annotation_strategy', 'label']); }); } @@ -46,7 +46,7 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('annotation_strategy_label'); - Schema::dropIfExists('annotation_strategy'); + Schema::dropIfExists('annotation_strategy_labels'); + Schema::dropIfExists('annotation_strategies'); } }; From 671bad7904b337037d776ca4a8453f5c7b7bf492 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Fri, 13 Mar 2026 14:18:51 +0100 Subject: [PATCH 22/93] Rename columns, add tests for PHP controllers : --- .../Projects/AnnotationStrategyController.php | 110 +++++++------ .../AnnotationStrategyLabelController.php | 90 +++++------ .../Projects/AnnotationStrategyController.php | 12 +- .../AnnotationStrategyControllerTest.php | 146 ++++++++++++++++++ .../AnnotationStrategyLabelControllerTest.php | 146 ++++++++++++++++++ .../AnnotationStrategyControllerTest.php | 32 ++++ 6 files changed, 434 insertions(+), 102 deletions(-) create mode 100644 tests/php/Http/Controllers/Api/Projects/AnnotationStrategyControllerTest.php create mode 100644 tests/php/Http/Controllers/Api/Projects/AnnotationStrategyLabelControllerTest.php create mode 100644 tests/php/Http/Controllers/Views/Projects/AnnotationStrategyControllerTest.php diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php index 75e5b26c1..d45bd420d 100644 --- a/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyController.php @@ -3,66 +3,82 @@ namespace Biigle\Http\Controllers\Api\Projects; use Biigle\Http\Controllers\Api\Controller; -use Biigle\Label; use Biigle\Project; use Biigle\AnnotationStrategy; -use Biigle\AnnotationStrategyLabel; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; +use Storage; class AnnotationStrategyController extends Controller { /** - * Get all image labels and annotation count for a given project + * Get the annotation strategy for the given project and the associated labels * - * @api {get} projects/:pid/strategy Get annotation labels with a annotation count + * @api {get} projects/:pid/annotation-strategy Get the annotation strategy for the given project * @apiGroup Projects * @apiName AnnotationStrategy * @apiParam {Number} id The Project ID - * @apiPermission projectMember - * @apiDescription Returns a collection of annotation labels and their counts in the project + * @apiPermission projectEditor + * @apiDescription Returns the annotation strategy and the associated labels * * @apiSuccessExample {json} Success response: - * [{"id":1, - * "name":"a", - * "color":"f2617c", - * "label_tree_id":1, - * "count":10}] + * [{"annotation_strategy":{ + * { + * "id":1, + * "project":2, + * "description":"strategy description" + * }, + * "annotation_strategy_labels" : { + * { + * "annotation_strategy": 1, + * "label":4, + * "shape":7, + * "description":"description of a label", + * "reference_image":"file.jpg", + * "label": + * { + * "id":4, + * "name":"something else", + * }, + * }}] * - * @param int $id Project ID - * @return \Illuminate\Support\Collection */ public function index($id) { $project = Project::findOrFail($id); - $this->authorize('access', $project); + $this->authorize('update', $project); $strategy = AnnotationStrategy::where(['project'=> $id]) ->firstOrFail(); - $strategyLabels = $strategy->strategyLabels()->with('label')->get(); + $strategyLabels = $strategy + ->strategyLabels() + ->select() + ->with('label') + ->get(); return ['annotation_strategy' => $strategy, 'annotation_strategy_labels' => $strategyLabels]; } - //TODO: form request for strategy /** - * Update a label. - * - * @api {put} labels/:id Update a label - * @apiGroup Labels - * @apiName UpdateLabels - * @apiPermission labelTreeEditor - * - * @apiParam {Number} id The label ID + * Update the annotation strategy for the given project * - * @apiParam (Attributes that can be updated) {String} name Name of the label. - * @apiParam (Attributes that can be updated) {String} color Color of the label as hexadecimal string (like `bada55`). May have an optional `#` prefix. - * @apiParam (Attributes that can be updated) {Number} parent_id ID of the parent label for ordering in a tree-like structure. + * @api {update} projects/:pid/annotation-strategy Update the annotation strategy for the given project + * @apiGroup Projects + * @apiName AnnotationStrategy + * @apiParam {Number} id The Project ID + * @apiParam {String} description A description on how to annotate the strategy + * @apiPermission projectAdmin + * @apiDescription Edit the annotation strategy associated with the given ID * - * @param UpdateLabel $request + * @param int $id Project ID */ - //public function update(UpdateAnnotationStrategy $request) - public function update(Request $request) + public function update(Request $request, $id) { + $request->validate([ + 'description' => 'required|string', + ]); + + $project = Project::findOrFail($id); + $this->authorize('update', $project); + AnnotationStrategy::updateOrCreate( ['project' => $request->id], ['description' => $request->description] @@ -70,25 +86,29 @@ public function update(Request $request) } /** - * Delete a label. - * - * @api {delete} labels/:id Delete a label - * @apiGroup Labels - * @apiName DestroyLabels - * @apiPermission labelTreeEditor - * @apiDescription A label may only be deleted if it doesn't have child labels and is - * not in use anywhere (e.g. attached to an annotation). + * Delete the annotation strategy for the given project * - * @apiParam {Number} id The label ID + * @api {delete} projects/:pid/annotation-strategy Delete the annotation strategy for the given project + * @apiGroup Projects + * @apiName AnnotationStrategy + * @apiParam {Number} id The Project ID + * @apiPermission projectAdmin + * @apiDescription Delete the annotation strategy associated with the given ID * - * @param DestroyLabel $request + * @param int $id Project ID */ - //public function destroy(DestroyAnnotationStrategy $request) public function delete(Request $request) { - //TODO: cleanup all files in the strategy labels - $annotationStrategy = AnnotationStrategy::where(['project'=> $request->id])->firstOrFail(); + $project = Project::findOrFail($request->id); + $this->authorize('update', $project); + $annotationStrategy = AnnotationStrategy::where(['project'=> $project->id])->firstOrFail(); $annotationStrategy->delete(); - } + //Cleanup the directory + $disk = Storage::disk(config('annotation_strategy.storage_disk')); + $url = "$project->id/"; + if ($disk->exists($url)) { + $disk->deleteDirectory($url); + } + } } diff --git a/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php index d215feedf..d73a26a82 100644 --- a/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php +++ b/app/Http/Controllers/Api/Projects/AnnotationStrategyLabelController.php @@ -3,61 +3,28 @@ namespace Biigle\Http\Controllers\Api\Projects; use Biigle\Http\Controllers\Api\Controller; -use Biigle\Label; use Biigle\Project; use Biigle\AnnotationStrategy; use Biigle\AnnotationStrategyLabel; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Storage; class AnnotationStrategyLabelController extends Controller { /** - * Get all image labels and annotation count for a given project + * Update the strategy for labels within an annotation strategy. Deletes the strategies for labels that are not used anymore. * - * @api {get} projects/:pid/strategy Get annotation labels with a annotation count + * @api {post} projects/:id/annotation-strategy-labels Update the strategy for labels within an annotation strategy * @apiGroup Projects - * @apiName AnnotationStrategy - * @apiParam {Number} id The Project ID - * @apiPermission projectMember - * @apiDescription Returns a collection of annotation labels and their counts in the project + * @apiName UpdateAnnotationStrategyLabels + * @apiPermission projectAdmin * - * @apiSuccessExample {json} Success response: - * [{"id":1, - * "name":"a", - * "color":"f2617c", - * "label_tree_id":1, - * "count":10}] - * - * @param int $id Project ID - * @return \Illuminate\Support\Collection - */ - public function index(Request $request) - { - $project = Project::findOrFail($request->id); - $this->authorize('editIn', $project); - $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); - return $annotationStrategy->strategyLabels()->get(); - } - - //TODO: form request for strategy - /** - * Upload the reference image for a label. - * - * @api {put} labels/:id Update a label - * @apiGroup Labels - * @apiName UpdateLabels - * @apiPermission labelTreeEditor - * - * @apiParam {Number} id The label ID + * @apiParam {Integer} id THe ID of the project for the annotation strategy for the labels + * @apiParam {Array} labels The IDs for the labels for the annotation strategy + * @apiParam {Array} shapes The IDs for the shapes for the annotation strategy + * @apiParam {Array} descriptions The IDs for the shapes for the annotation strategy + * @apiParam {Array} reference_images The name of the files of the reference images * - * @apiParam (Attributes that can be updated) {String} name Name of the label. - * @apiParam (Attributes that can be updated) {String} color Color of the label as hexadecimal string (like `bada55`). May have an optional `#` prefix. - * @apiParam (Attributes that can be updated) {Number} parent_id ID of the parent label for ordering in a tree-like structure. - * - * @param UpdateLabel $request */ - //public function update(UpdateAnnotationStrategy $request) public function update(Request $request) { $project = Project::findOrFail($request->id); @@ -69,7 +36,7 @@ public function update(Request $request) $referenceImages = $request->reference_images; $annotationStrategy = AnnotationStrategy::where(['project' => $project->id])->firstOrFail(); - $aslToDelete = $annotationStrategy->strategyLabels()->whereNotIn('label_id', $labels); + $aslToDelete = $annotationStrategy->strategyLabels()->whereNotIn('label', $labels); $aslToDelete->delete(); $disk = Storage::disk(config('annotation_strategy.storage_disk')); @@ -83,21 +50,34 @@ public function update(Request $request) for ($i = 0; $i < count($labels); $i++) { AnnotationStrategyLabel::updateOrCreate( [ - 'annotation_strategy_id' => $annotationStrategy->id, - 'label_id' => $labels[$i], + 'annotation_strategy' => $annotationStrategy->id, + 'label' => $labels[$i], ], [ - 'shape_id' => $shapes[$i], + 'shape' => $shapes[$i], 'description' => $descriptions[$i], 'reference_image' => $referenceImages[$i], ] - )->toSql(); + ); } } + /** + * Store a reference image. Returns the name with which the file is stored. + * + * @api {post} projects/:id/annotation-strategy-labels/upload-image Upload a reference image + * @apiGroup Projects + * @apiName StoreReferenceImage + * @apiPermission projectAdmin + * + * @apiParam {Integer} id The ID of the project for the annotation strategy for the labels + * @apiParam {File} file The reference image to upload + * + * @apiSuccessExample {json} Success response: + * {"filename": "nameOfTheFile"} + */ public function storeReferenceImage(Request $request) { - //TODO: validate request? //TODO: should we send it to a temporary dir? //TODO: should we resize the image? $project = Project::findOrFail($request->id); @@ -113,11 +93,19 @@ public function storeReferenceImage(Request $request) return ['filename' => $name]; } + /** + * Delete a reference image. + * + * @api {delete} projects/:id/annotation-strategy-labels/delete-image Delete a reference image + * @apiGroup Projects + * @apiName DeleteReferenceImage + * @apiPermission projectAdmin + * + * @apiParam {Integer} id The ID of the project for the annotation strategy for the labels + * + */ public function deleteReferenceImage(Request $request) { - //TODO: validate request? - //TODO: should we send it to a temporary dir? - //TODO: should we resize the image? $project = Project::findOrFail($request->id); $name = $request->input('reference_image'); diff --git a/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php index 7bac1d3f7..9cc40b6c5 100644 --- a/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php +++ b/app/Http/Controllers/Views/Projects/AnnotationStrategyController.php @@ -5,13 +5,10 @@ use Biigle\Http\Controllers\Views\Controller; use Biigle\AnnotationStrategy; use Biigle\Project; -use Biigle\AnnotationStrategyLabel; use Biigle\Role; use Biigle\Shape; -use Biigle\User; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Js; class AnnotationStrategyController extends Controller { @@ -30,7 +27,7 @@ public function show(Request $request, int $id) $user = $request->user(); if (!$user->can('sudo')) { - $this->authorize('access', $project); + $this->authorize('editIn', $project); } $userProject = $request->user()->projects()->where('id', $id)->first(); @@ -43,7 +40,7 @@ public function show(Request $request, int $id) $annotationStrategy = AnnotationStrategy::where(['project' => $id])->first(); - $isAdmin = $user->role_id === Role::adminId() || !$user->can('sudo'); + $isAdmin = $user->role_id === Role::adminId() || $user->can('sudo'); $labelTrees = $project->labelTrees() ->select('id', 'name', 'version_id') @@ -71,7 +68,10 @@ public function show(Request $request, int $id) abort(Response::HTTP_NOT_FOUND); } - $annotationStrategyLabels = $annotationStrategy->strategyLabels()->with('label')->get(); + $annotationStrategyLabels = $annotationStrategy->strategyLabels() + ->with('label') + ->get() + ->toArray(); return view('projects.show.annotation-strategy', [ "project" => $project, diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyControllerTest.php new file mode 100644 index 000000000..08fc5dd5e --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyControllerTest.php @@ -0,0 +1,146 @@ +project()->id; + $path = "/api/v1/projects/{$id}/annotation-strategy"; + $this->doTestApiRoute('GET', $path); + + $this->beGuest(); + $this->get($path) + ->assertStatus(403); + + $this->beUser(); + $this->get($path) + ->assertStatus(403); + + $this->beEditor(); + $this->get($path) + ->assertStatus(403); + + $this->beAdmin(); + $this->get($path) + ->assertStatus(404); + + $as1 = AnnotationStrategy::create(['project' => $id, 'description' => 'someDescription']); + + $this->get($path) + ->assertStatus(200) + ->assertExactJson( + [ + 'annotation_strategy' => + ['id' => $as1->id, 'project' => $id, 'description' => 'someDescription'], + 'annotation_strategy_labels' => [], + ] + ); + $label = Label::factory()->create(); + + $asl1 = AnnotationStrategyLabel::create( + [ + 'annotation_strategy' => $as1->id, + 'label' => $label->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => 'file.jpg', + ]); + + $this->get($path) + ->assertStatus(200) + ->assertJson( + [ + 'annotation_strategy' => + ['id' => $as1->id, 'project' => $id, 'description' => 'someDescription'], + 'annotation_strategy_labels' => [[ + 'description' => 'labelDescription', + 'label' => $label->toArray(), + 'reference_image' => 'file.jpg', + 'shape' => Shape::polygonId(), + ]], + ] + ); + } + public function testUpdate() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-strategy"; + $this->doTestApiRoute('POST', $path); + + $data = ['description' => 'someDescription']; + + $this->beGuest(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->json('POST', $path, $data) + ->assertStatus(200); + + $as1 = AnnotationStrategy::where(['project' => $id])->first(); + + $this->assertSame($as1->project, $id); + $this->assertSame($as1->description, 'someDescription'); + + $this->json('POST', $path, ['project' => $id, 'description' => 'someNewDescription']) + ->assertStatus(200); + + $as2 = AnnotationStrategy::where(['project' => $id])->first(); + + $this->assertSame($as2->id, $as1->id); + $this->assertSame($as2->project, $id); + $this->assertSame($as2->description, 'someNewDescription'); + } + + public function testDelete() + { + $id = $this->project()->id; + config(['annotation_strategy.storage_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $path = "/api/v1/projects/{$id}/annotation-strategy"; + $this->doTestApiRoute('DELETE', $path); + + $this->beGuest(); + $this->delete($path) + ->assertStatus(403); + + $this->beUser(); + $this->delete($path) + ->assertStatus(403); + + $this->beEditor(); + $this->delete($path) + ->assertStatus(403); + + $this->beAdmin(); + $this->delete($path) + ->assertStatus(404); + + AnnotationStrategy::create(['project' => $id, 'description' => 'someDescription']); + + //We also test that the files are cleared + $disk->put("$id/somefile.png", 'content'); + + $this->delete($path) + ->assertStatus(200); + + $this->assertEmpty(AnnotationStrategy::where(['project' => $id])->first()); + $disk->assertMissing("$id/somefile.png"); + } +} diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyLabelControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyLabelControllerTest.php new file mode 100644 index 000000000..d06cf4874 --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationStrategyLabelControllerTest.php @@ -0,0 +1,146 @@ +project()->id; + $as = AnnotationStrategy::create(['project' => $id, 'description' => 'someDescription']); + $path = "/api/v1/projects/{$id}/annotation-strategy-label"; + $label = Label::factory()->create(); + $data = [ + 'annotation_strategy' => $as->id, + 'labels' => [$label->id], + 'shapes' => [Shape::polygonId()], + 'descriptions' => ['labelDescription'], + 'reference_images' => ['file.jpg'], + ]; + $this->doTestApiRoute('POST', $path); + + $this->beGuest(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->post($path, $data) + ->assertStatus(200); + + $asl = AnnotationStrategyLabel::where(['annotation_strategy' => $as->id]) + ->get() + ->toArray(); + + $expected = [[ + 'annotation_strategy' => $as->id, + 'label' => $label->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => 'file.jpg', + ]]; + + $this->assertEquals($asl, $expected); + + //Update two other labels, the first label should not be there. + $label2 = Label::factory()->create(); + $label3 = Label::factory()->create(); + $data = [ + 'annotation_strategy' => $as->id, + 'labels' => [$label2->id, $label3->id], + 'shapes' => [null, Shape::circleId()], + 'descriptions' => ['labelDescription', 'aDifferentDescription'], + 'reference_images' => ['file.png', null], + ]; + + $this->post($path, $data) + ->assertStatus(200); + + $asl = AnnotationStrategyLabel::where(['annotation_strategy' => $as->id]) + ->get() + ->toArray(); + + $expected = [ + [ + 'annotation_strategy' => $as->id, + 'label' => $label2->id, + 'shape' => null, + 'description' => 'labelDescription', + 'reference_image' => 'file.png', + ], + [ + 'annotation_strategy' => $as->id, + 'label' => $label3->id, + 'shape' => Shape::circleId(), + 'description' => 'aDifferentDescription', + 'reference_image' => null, + ], + ]; + + $asl = AnnotationStrategyLabel::where(['annotation_strategy' => $as->id]) + ->get() + ->toArray(); + + $this->assertEquals($asl, $expected); + } + + public function testReferenceImageUploadAndDelete() + { + config(['annotation_strategy.storage_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + $id = $this->project()->id; + + //upload + $path = "/api/v1/projects/{$id}/annotation-strategy-label/upload-image"; + + $textData = ['file' => UploadedFile::fake()->create('file.txt')]; + $imageData = ['file' => UploadedFile::fake()->create('image.png')]; + + $this->doTestApiRoute('POST', $path); + + $this->beGuest(); + $this->post($path, $imageData) + ->assertStatus(403); + + $this->beUser(); + $this->post($path, $imageData) + ->assertStatus(403); + + $this->beEditor(); + $this->post($path, $imageData) + ->assertStatus(403); + + $this->beAdmin(); + $response = $this->post($path, $imageData); + $response->assertStatus(200); + $filename = $response->json()['filename']; + + $this->post($path, $textData) + ->assertStatus(302); + + $disk->assertExists("{$id}/{$filename}"); + + //delete + $path = "/api/v1/projects/{$id}/annotation-strategy-label/delete-image"; + + $this->delete($path, ['filename' => $filename]) + ->assertStatus(200); + + $disk->assertMissing("{$id}/image.png"); + } +} diff --git a/tests/php/Http/Controllers/Views/Projects/AnnotationStrategyControllerTest.php b/tests/php/Http/Controllers/Views/Projects/AnnotationStrategyControllerTest.php new file mode 100644 index 000000000..7d3150a93 --- /dev/null +++ b/tests/php/Http/Controllers/Views/Projects/AnnotationStrategyControllerTest.php @@ -0,0 +1,32 @@ +project()->id; + + $path = "projects/{$id}/annotation-strategy"; + $this->get($path)->assertStatus(302); + + $this->beGuest(); + $this->get($path)->assertStatus(403); + + $this->beEditor(); + $this->get($path)->assertStatus(404); + + AnnotationStrategy::create(['project' => $id, 'description' => 'someDescription']); + + $this->get($path)->assertStatus(200); + + $this->beAdmin(); + $this->get($path)->assertStatus(200); + + $this->beGlobalAdmin(); + $this->get($path)->assertStatus(200); + } +} + From cf5ad6ef9f83ac76c265af8fc92f7110258f8224 Mon Sep 17 00:00:00 2001 From: Davide Brembilla Date: Fri, 13 Mar 2026 14:19:51 +0100 Subject: [PATCH 23/93] Fix style, rename attributes to follow columns --- .../components/annotationStrategy.vue | 12 ++--- .../components/annotationStrategyEditor.vue | 54 ++++++++++++------- .../annotationStrategyLabelImage.vue | 20 +++---- resources/assets/sass/projects/main.scss | 10 ++-- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/resources/assets/js/projects/components/annotationStrategy.vue b/resources/assets/js/projects/components/annotationStrategy.vue index ddad1dd10..d166c9395 100644 --- a/resources/assets/js/projects/components/annotationStrategy.vue +++ b/resources/assets/js/projects/components/annotationStrategy.vue @@ -54,8 +54,8 @@ {{ annotationStrategyLabel.description }}
    - - {{ mapShape(annotationStrategyLabel.shape_id) }} + + {{ mapShape(annotationStrategyLabel.shape) }}
    {{ annotationStrategyLabel.description }}
    - - {{ mapShape(annotationStrategyLabel.shape_id) }} + + {{ mapShape(annotationStrategyLabel.shape) }}
    @@ -146,12 +147,13 @@
    -
    +