Skip to content

Commit 5bbc13f

Browse files
authored
Storage and retrieval of project settings (#918)
* chore: separate fields & type hint backreferences in Project model * feat: basic method to store user-defined settings for projects * fix: don't fail whole migration if create roles task fails * fix: admin form with too many choices * feat: return nested representations of related models in settings
1 parent e37857c commit 5bbc13f

File tree

7 files changed

+251
-21
lines changed

7 files changed

+251
-21
lines changed

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: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from ami.base.serializers import DefaultSerializer, MinimalNestedModelSerializer, get_current_user, reverse_with_params
1111
from ami.jobs.models import Job
1212
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
13+
from ami.ml.models import Algorithm, Pipeline
14+
from ami.ml.serializers import AlgorithmSerializer, PipelineNestedSerializer
1515
from ami.users.models import User
1616
from ami.users.roles import ProjectManager
1717

@@ -25,6 +25,7 @@
2525
Occurrence,
2626
Page,
2727
Project,
28+
ProjectSettingsMixin,
2829
S3StorageSource,
2930
Site,
3031
SourceImage,
@@ -255,6 +256,18 @@ class Meta:
255256
]
256257

257258

259+
class TaxonNoParentNestedSerializer(DefaultSerializer):
260+
class Meta:
261+
model = Taxon
262+
fields = [
263+
"id",
264+
"name",
265+
"rank",
266+
"details",
267+
"gbif_taxon_key",
268+
]
269+
270+
258271
class ProjectListSerializer(DefaultSerializer):
259272
deployments_count = serializers.IntegerField(read_only=True)
260273

@@ -272,10 +285,46 @@ class Meta:
272285
]
273286

274287

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

280329
def get_feature_flags(self, obj):
281330
if obj.feature_flags:
@@ -289,6 +338,7 @@ class Meta:
289338
"summary_data", # @TODO move to a 2nd request, it's too slow
290339
"owner",
291340
"feature_flags",
341+
"settings",
292342
]
293343

294344

@@ -457,18 +507,6 @@ def get_occurrences(self, obj):
457507
)
458508

459509

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-
472510
class TaxonParentSerializer(serializers.Serializer):
473511
id = serializers.IntegerField()
474512
name = serializers.CharField()
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+
]

ami/main/models.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ami.base.fields import DateStringField
3030
from ami.base.models import BaseModel
3131
from ami.main import charts
32+
from ami.main.models_future.projects import ProjectSettingsMixin
3233
from ami.users.models import User
3334
from ami.utils.schemas import OrderedEnum
3435

@@ -201,32 +202,32 @@ class ProjectFeatureFlags(pydantic.BaseModel):
201202

202203

203204
@final
204-
class Project(BaseModel):
205+
class Project(ProjectSettingsMixin, BaseModel):
205206
""" """
206207

207208
name = models.CharField(max_length=_POST_TITLE_MAX_LENGTH)
208209
description = models.TextField(blank=True)
209210
image = models.ImageField(upload_to="projects", blank=True, null=True)
210211
owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="projects")
211212
members = models.ManyToManyField(User, related_name="user_projects", blank=True)
213+
212214
feature_flags = SchemaField(
213215
ProjectFeatureFlags,
214216
default=default_feature_flags,
215217
null=False,
216218
blank=True,
217219
)
218220

221+
active = models.BooleanField(default=True)
222+
priority = models.IntegerField(default=1)
223+
219224
# Backreferences for type hinting
220225
captures: models.QuerySet["SourceImage"]
221226
deployments: models.QuerySet["Deployment"]
222227
events: models.QuerySet["Event"]
223228
occurrences: models.QuerySet["Occurrence"]
224229
taxa: models.QuerySet["Taxon"]
225230
taxa_lists: models.QuerySet["TaxaList"]
226-
227-
active = models.BooleanField(default=True)
228-
priority = models.IntegerField(default=1)
229-
230231
devices: models.QuerySet["Device"]
231232
sites: models.QuerySet["Site"]
232233
jobs: models.QuerySet["Job"]

ami/main/models_future/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
This is a temporary module for adding new models and features while we migrate models.py to a more modular structure.
3+
Once the migration is complete, this module will be removed and the models will be moved to their respective files.
4+
5+
This will happen after the current PRs are merged to minimize conflicts.
6+
7+
Current models will be moved to:
8+
9+
models/
10+
├── __init__.py # Import everything for backward compatibility
11+
├── base.py # BaseModel and mixins
12+
├── projects.py # Project, Device, Site, Deployment
13+
├── storage.py # S3StorageSource, SourceImageUpload
14+
├── images.py # SourceImage, Event, SourceImageCollection
15+
├── taxonomy.py # Taxon, TaxaList, Tag
16+
├── detection.py # Detection, Classification, Occurrence
17+
├── identification.py # Identification
18+
├── content.py # Page, BlogPost
19+
└── enums.py # TaxonRank and other enums
20+
"""

ami/main/models_future/projects.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from django.db import models
2+
3+
4+
class ProjectSettingsMixin(models.Model):
5+
"""
6+
User definable settings for projects.
7+
8+
This is a mixin that will be flattened out into the final model.
9+
It allows us to organize user-defined project settings in their own class
10+
without needing to create a separate model.
11+
"""
12+
13+
default_processing_pipeline = models.ForeignKey(
14+
"ml.Pipeline",
15+
on_delete=models.SET_NULL,
16+
null=True,
17+
blank=True,
18+
related_name="default_projects",
19+
help_text=(
20+
"The default pipeline to use for processing images in this project. "
21+
"This is used to determine which processing service to run on new images. "
22+
# "Use the global unique key of the Pipeline, which may be a slug or a UUID. "
23+
# "Cannot be a Pipeline instance yet, because the Pipelines have likely not been created yet."
24+
),
25+
)
26+
27+
session_time_gap_seconds = models.IntegerField(
28+
default=60 * 60 * 2, # Default to 2 hours
29+
help_text="Time gap in seconds to consider a new session",
30+
)
31+
32+
default_filters_score_threshold = models.FloatField(
33+
default=0.5,
34+
help_text="Default score threshold for filtering occurrences",
35+
)
36+
37+
default_filters_include_taxa = models.ManyToManyField(
38+
"Taxon",
39+
related_name="include_taxa_default_projects",
40+
blank=True,
41+
help_text=(
42+
"Taxa that are included by default in the occurrence filters and metrics. "
43+
"For example, the top-level taxa like 'Moths' or 'Arthropods'. "
44+
),
45+
)
46+
47+
default_filters_exclude_taxa = models.ManyToManyField(
48+
"Taxon",
49+
related_name="exclude_taxa_default_projects",
50+
blank=True,
51+
help_text=(
52+
"Taxa that are excluded by default in the occurrence filters and metrics. " "For example, 'Not a Moth'."
53+
),
54+
)
55+
56+
# settings_updated_at = models.DateTimeField(auto_now=True, help_text="Last time any setting was updated")
57+
58+
class Meta:
59+
# Do not create a separate table for this mixin
60+
abstract = True
61+
62+
@classmethod
63+
def get_settings_field_names(cls) -> list[str]:
64+
"""
65+
Automatically discover settings fields by comparing with BaseModel.
66+
67+
This finds fields that exist in the final model but not in BaseModel,
68+
which means they were added by this mixin.
69+
"""
70+
from ami.base.models import BaseModel # Adjust import as needed
71+
72+
# Get all field names from the final model
73+
all_fields = {f.name for f in cls._meta.get_fields()}
74+
75+
# Get all field names from BaseModel
76+
base_fields = {f.name for f in BaseModel._meta.get_fields()}
77+
78+
# Settings fields are those not in BaseModel
79+
settings_fields = all_fields - base_fields
80+
81+
# Filter out reverse relations and other auto-generated fields
82+
real_settings_fields = []
83+
for field_name in settings_fields:
84+
try:
85+
field = cls._meta.get_field(field_name)
86+
# Skip auto-created fields and foreign key ID fields
87+
if not (field.auto_created or field.name.endswith("_id")):
88+
real_settings_fields.append(field_name)
89+
except Exception:
90+
# Skip fields that can't be retrieved (like reverse relations)
91+
continue
92+
93+
return sorted(real_settings_fields)

ami/users/signals.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@ def create_roles(sender, **kwargs):
1515
"""Creates predefined roles with specific permissions ."""
1616

1717
logger.info("Creating roles for all projects")
18-
for project in Project.objects.all():
19-
create_roles_for_project(project)
18+
try:
19+
for project in Project.objects.all():
20+
try:
21+
create_roles_for_project(project)
22+
except Exception as e:
23+
logger.warning(f"Failed to create roles for project {project.pk} ({project.name}): {e}")
24+
continue
25+
except Exception as e:
26+
logger.warning(
27+
f"Failed to create roles during migration: {e}. This can be run manually via management command."
28+
)
2029

2130

2231
@receiver(m2m_changed, sender=Group.user_set.through)

0 commit comments

Comments
 (0)