Skip to content

Commit 10edc4b

Browse files
authored
Merge branch 'main' into feat/adc-importer
2 parents 3b261c1 + ac4f705 commit 10edc4b

29 files changed

+669
-96
lines changed

.envs/.ci/.django

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ MINIO_DEFAULT_BUCKET=ami-ci
1616
MINIO_STORAGE_USE_HTTPS=False
1717
MINIO_TEST_BUCKET=ami-test-ci
1818
MINIO_BROWSER_REDIRECT_URL=http://minio:9001
19+
20+
DEFAULT_PROCESSING_SERVICE_NAME=Test Processing Service
21+
DEFAULT_PROCESSING_SERVICE_ENDPOINT=http://ml_backend:2000

ami/base/views.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,23 @@ class ProjectMixin:
2222
def get_active_project(self) -> Project:
2323
from ami.base.serializers import SingleParamSerializer
2424

25+
param = "project_id"
26+
2527
project_id = None
2628
# Extract from URL `/projects/` is in the url path
2729
if "/projects/" in self.request.path:
2830
project_id = self.kwargs.get("pk")
2931

3032
# If not in URL, try query parameters
3133
if not project_id:
32-
if self.require_project:
33-
project_id = SingleParamSerializer[int].clean(
34-
param_name="project_id",
35-
field=serializers.IntegerField(required=True, min_value=0),
36-
data=self.request.query_params,
37-
)
38-
else:
39-
project_id = self.request.query_params.get("project_id") # No validation
34+
# Look for project_id in GET query parameters or POST data
35+
# POST data returns a list of ints, but QueryDict.get() returns a single value
36+
project_id = self.request.query_params.get(param) or self.request.data.get(param)
37+
38+
project_id = SingleParamSerializer[int].clean(
39+
param_name=param,
40+
field=serializers.IntegerField(required=self.require_project, min_value=0),
41+
data={param: project_id} if project_id else {},
42+
)
4043

4144
return get_object_or_404(Project, id=project_id) if project_id else None

ami/main/admin.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def save_related(self, request, form, formsets, change):
7676
filter_horizontal = ("members",)
7777

7878
inlines = [ProjectPipelineConfigInline]
79+
autocomplete_fields = ("default_filters_include_taxa", "default_filters_exclude_taxa")
7980

8081
fieldsets = (
8182
(
@@ -90,6 +91,18 @@ def save_related(self, request, form, formsets, change):
9091
)
9192
},
9293
),
94+
(
95+
"Settings",
96+
{
97+
"fields": (
98+
"default_processing_pipeline",
99+
"session_time_gap_seconds",
100+
"default_filters_score_threshold",
101+
"default_filters_include_taxa",
102+
"default_filters_exclude_taxa",
103+
),
104+
},
105+
),
93106
(
94107
"Ownership & Access",
95108
{

ami/main/api/serializers.py

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import datetime
22

3-
from django.core.exceptions import ValidationError as DjangoValidationError
43
from django.db.models import QuerySet
54
from guardian.shortcuts import get_perms
65
from rest_framework import serializers
76
from rest_framework.request import Request
87

98
from ami.base.fields import DateStringField
10-
from ami.base.serializers import DefaultSerializer, MinimalNestedModelSerializer, get_current_user, reverse_with_params
9+
from ami.base.serializers import DefaultSerializer, MinimalNestedModelSerializer, reverse_with_params
1110
from ami.jobs.models import Job
12-
from ami.main.models import Tag, create_source_image_from_upload
13-
from ami.ml.models import Algorithm
14-
from ami.ml.serializers import AlgorithmSerializer
11+
from ami.main.models import Tag
12+
from ami.ml.models import Algorithm, Pipeline
13+
from ami.ml.serializers import AlgorithmSerializer, PipelineNestedSerializer
1514
from ami.users.models import User
1615
from ami.users.roles import ProjectManager
1716

@@ -25,14 +24,14 @@
2524
Occurrence,
2625
Page,
2726
Project,
27+
ProjectSettingsMixin,
2828
S3StorageSource,
2929
Site,
3030
SourceImage,
3131
SourceImageCollection,
3232
SourceImageUpload,
3333
TaxaList,
3434
Taxon,
35-
validate_filename_timestamp,
3635
)
3736

3837

@@ -255,6 +254,18 @@ class Meta:
255254
]
256255

257256

257+
class TaxonNoParentNestedSerializer(DefaultSerializer):
258+
class Meta:
259+
model = Taxon
260+
fields = [
261+
"id",
262+
"name",
263+
"rank",
264+
"details",
265+
"gbif_taxon_key",
266+
]
267+
268+
258269
class ProjectListSerializer(DefaultSerializer):
259270
deployments_count = serializers.IntegerField(read_only=True)
260271

@@ -272,10 +283,46 @@ class Meta:
272283
]
273284

274285

286+
class ProjectSettingsSerializer(DefaultSerializer):
287+
default_processing_pipeline = PipelineNestedSerializer(read_only=True)
288+
default_processing_pipeline_id = serializers.PrimaryKeyRelatedField(
289+
queryset=Pipeline.objects.all(),
290+
source="default_processing_pipeline",
291+
write_only=True,
292+
required=False,
293+
allow_null=True,
294+
)
295+
default_filters_include_taxa = TaxonNoParentNestedSerializer(read_only=True, many=True)
296+
default_filters_include_taxa_ids = serializers.PrimaryKeyRelatedField(
297+
queryset=Taxon.objects.all(),
298+
many=True,
299+
source="default_filters_include_taxa",
300+
write_only=True,
301+
required=False,
302+
)
303+
default_filters_exclude_taxa = TaxonNoParentNestedSerializer(read_only=True, many=True)
304+
default_filters_exclude_taxa_ids = serializers.PrimaryKeyRelatedField(
305+
queryset=Taxon.objects.all(),
306+
many=True,
307+
source="default_filters_exclude_taxa",
308+
write_only=True,
309+
required=False,
310+
)
311+
312+
class Meta:
313+
model = Project
314+
fields = ProjectSettingsMixin.get_settings_field_names() + [
315+
"default_processing_pipeline_id",
316+
"default_filters_include_taxa_ids",
317+
"default_filters_exclude_taxa_ids",
318+
]
319+
320+
275321
class ProjectSerializer(DefaultSerializer):
276322
deployments = DeploymentNestedSerializerWithLocationAndCounts(many=True, read_only=True)
277323
feature_flags = serializers.SerializerMethodField()
278324
owner = UserNestedSerializer(read_only=True)
325+
settings = ProjectSettingsSerializer(source="*", required=False)
279326

280327
def get_feature_flags(self, obj):
281328
if obj.feature_flags:
@@ -289,6 +336,7 @@ class Meta:
289336
"summary_data", # @TODO move to a 2nd request, it's too slow
290337
"owner",
291338
"feature_flags",
339+
"settings",
292340
]
293341

294342

@@ -457,18 +505,6 @@ def get_occurrences(self, obj):
457505
)
458506

459507

460-
class TaxonNoParentNestedSerializer(DefaultSerializer):
461-
class Meta:
462-
model = Taxon
463-
fields = [
464-
"id",
465-
"name",
466-
"rank",
467-
"details",
468-
"gbif_taxon_key",
469-
]
470-
471-
472508
class TaxonParentSerializer(serializers.Serializer):
473509
id = serializers.IntegerField()
474510
name = serializers.CharField()
@@ -1047,30 +1083,6 @@ class Meta:
10471083
"created_at",
10481084
]
10491085

1050-
def create(self, validated_data):
1051-
# Add the user to the validated data
1052-
request = self.context.get("request")
1053-
user = get_current_user(request)
1054-
# @TODO IMPORTANT ensure current user is a member of the deployment's project
1055-
obj = SourceImageUpload.objects.create(user=user, **validated_data)
1056-
source_image = create_source_image_from_upload(
1057-
obj.image,
1058-
obj.deployment,
1059-
request,
1060-
)
1061-
if source_image is not None:
1062-
obj.source_image = source_image # type: ignore
1063-
obj.save()
1064-
return obj
1065-
1066-
def validate_image(self, value):
1067-
# Ensure that image filename contains a timestamp
1068-
try:
1069-
validate_filename_timestamp(value.name)
1070-
except DjangoValidationError as e:
1071-
raise serializers.ValidationError(str(e))
1072-
return value
1073-
10741086

10751087
class SourceImageCollectionCommonKwargsSerializer(serializers.Serializer):
10761088
# The most common kwargs for the sampling methods

ami/main/api/views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,7 @@ class SourceImageUploadViewSet(DefaultViewSet, ProjectMixin):
760760

761761
serializer_class = SourceImageUploadSerializer
762762
permission_classes = [SourceImageUploadCRUDPermission]
763+
require_project = True
763764

764765
def get_queryset(self) -> QuerySet:
765766
# Only allow users to see their own uploads
@@ -772,6 +773,35 @@ def get_queryset(self) -> QuerySet:
772773
# This is the maximum limit for manually uploaded captures
773774
pagination_class.default_limit = 20
774775

776+
def perform_create(self, serializer):
777+
"""
778+
Save the SourceImageUpload with the current user and create the associated SourceImage.
779+
"""
780+
from ami.base.serializers import get_current_user
781+
from ami.main.models import create_source_image_from_upload
782+
783+
# Get current user from request
784+
user = get_current_user(self.request)
785+
project = self.get_active_project()
786+
787+
# Create the SourceImageUpload object with the user
788+
obj = serializer.save(user=user)
789+
790+
# Get process_now flag from project feature flags
791+
process_now = project.feature_flags.auto_processs_manual_uploads
792+
793+
# Create source image from the upload
794+
source_image = create_source_image_from_upload(
795+
image=obj.image,
796+
deployment=obj.deployment,
797+
request=self.request,
798+
process_now=process_now,
799+
)
800+
801+
# Update the source_image reference and save
802+
obj.source_image = source_image
803+
obj.save()
804+
775805

776806
class DetectionViewSet(DefaultViewSet, ProjectMixin):
777807
"""
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.10 on 2025-08-08 15:30
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("main", "0063_alter_sourceimagecollection_method"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="project",
14+
name="description",
15+
field=models.TextField(blank=True),
16+
),
17+
]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 4.2.10 on 2025-08-11 22:19
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("ml", "0022_alter_pipeline_default_config"),
10+
("main", "0064_alter_project_description"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="project",
16+
name="default_filters_exclude_taxa",
17+
field=models.ManyToManyField(
18+
blank=True,
19+
help_text="Taxa that are excluded by default in the occurrence filters and metrics. For example, 'Not a Moth'.",
20+
related_name="exclude_taxa_default_projects",
21+
to="main.taxon",
22+
),
23+
),
24+
migrations.AddField(
25+
model_name="project",
26+
name="default_filters_include_taxa",
27+
field=models.ManyToManyField(
28+
blank=True,
29+
help_text="Taxa that are included by default in the occurrence filters and metrics. For example, the top-level taxa like 'Moths' or 'Arthropods'. ",
30+
related_name="include_taxa_default_projects",
31+
to="main.taxon",
32+
),
33+
),
34+
migrations.AddField(
35+
model_name="project",
36+
name="default_filters_score_threshold",
37+
field=models.FloatField(default=0.5, help_text="Default score threshold for filtering occurrences"),
38+
),
39+
migrations.AddField(
40+
model_name="project",
41+
name="default_processing_pipeline",
42+
field=models.ForeignKey(
43+
blank=True,
44+
help_text="The default pipeline to use for processing images in this project. This is used to determine which processing service to run on new images. ",
45+
null=True,
46+
on_delete=django.db.models.deletion.SET_NULL,
47+
related_name="default_projects",
48+
to="ml.pipeline",
49+
),
50+
),
51+
migrations.AddField(
52+
model_name="project",
53+
name="session_time_gap_seconds",
54+
field=models.IntegerField(default=7200, help_text="Time gap in seconds to consider a new session"),
55+
),
56+
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 4.2.10 on 2025-08-08 21:53
2+
3+
import ami.main.models
4+
from django.db import migrations, models
5+
import django_pydantic_field.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("main", "0065_project_default_filters_exclude_taxa_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="project",
16+
name="feature_flags",
17+
field=django_pydantic_field.fields.PydanticSchemaField(
18+
blank=True,
19+
config=None,
20+
default={"auto_processs_manual_uploads": False, "tags": False},
21+
schema=ami.main.models.ProjectFeatureFlags,
22+
),
23+
),
24+
migrations.AlterField(
25+
model_name="sourceimageupload",
26+
name="image",
27+
field=models.ImageField(upload_to=ami.main.models.upload_to_with_deployment),
28+
),
29+
]

0 commit comments

Comments
 (0)