Skip to content

Commit 8ebf62e

Browse files
Merge branch 'feat/apply-score-filter' of https://github.com/RolnickLab/antenna into feat/apply-score-filter
2 parents cab5ef6 + 8968940 commit 8ebf62e

File tree

80 files changed

+2811
-592
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+2811
-592
lines changed

.github/workflows/test.backend.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
runs-on: ubuntu-latest
2424
steps:
2525
- name: Checkout Code Repository
26-
uses: actions/checkout@v4
26+
uses: actions/checkout@v5
2727

2828
- name: Set up Python
2929
uses: actions/setup-python@v5
@@ -37,7 +37,7 @@ jobs:
3737
runs-on: ubuntu-latest
3838
steps:
3939
- name: Checkout Code Repository
40-
uses: actions/checkout@v4
40+
uses: actions/checkout@v5
4141

4242
- name: Build the Stack
4343
run: docker compose -f docker-compose.ci.yml build

.github/workflows/test.frontend.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
working-directory: ui
3131
steps:
3232
- name: Checkout Code Repository
33-
uses: actions/checkout@v4
33+
uses: actions/checkout@v5
3434

3535
- name: Install Node.js
3636
uses: actions/setup-node@v4
@@ -53,7 +53,7 @@ jobs:
5353
working-directory: ui
5454
steps:
5555
- name: Checkout Code Repository
56-
uses: actions/checkout@v4
56+
uses: actions/checkout@v5
5757

5858
- name: Install Node.js
5959
uses: actions/setup-node@v4

ami/base/models.py

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,154 @@
11
from django.contrib.auth.models import AbstractUser, AnonymousUser
22
from django.db import models
3+
from django.db.models import Q, QuerySet
34
from guardian.shortcuts import get_perms
45

56
import ami.tasks
7+
from ami.users.models import User
8+
9+
10+
def has_one_to_many_project_relation(model: type[models.Model]) -> bool:
11+
"""
12+
Returns True if the model has any ForeignKey or OneToOneField relationship to Project.
13+
"""
14+
from ami.main.models import Project
15+
16+
for field in model._meta.get_fields():
17+
if isinstance(field, (models.ForeignKey, models.OneToOneField)) and field.related_model == Project:
18+
return True
19+
20+
return False
21+
22+
23+
def has_many_to_many_project_relation(model: type[models.Model]) -> bool:
24+
"""
25+
Returns True if the model has any forward or reverse ManyToMany relationship to Project.
26+
"""
27+
from ami.main.models import Project
28+
29+
# Forward M2M
30+
for field in model._meta.get_fields():
31+
if isinstance(field, models.ManyToManyField) and field.related_model == Project:
32+
return True
33+
34+
# Reverse M2M
35+
for rel in Project._meta.related_objects: # type: ignore
36+
if rel.related_model == model and rel.many_to_many:
37+
return True
38+
39+
return False
40+
41+
42+
class BaseQuerySet(QuerySet):
43+
def visible_for_user(self, user: User | AnonymousUser) -> QuerySet:
44+
"""
45+
Filter queryset to include only objects whose related draft projects
46+
are visible to the given user. Only superusers, project owners,
47+
or members are allowed to view draft projects and their related objects.
48+
"""
49+
from ami.main.models import Project
50+
51+
# Superusers can see everything
52+
if user.is_superuser:
53+
return self
54+
55+
# Anonymous users can only see non-draft projects/objects
56+
is_anonymous = isinstance(user, AnonymousUser)
57+
58+
model = self.model
59+
60+
# Handle Project model directly
61+
if model == Project:
62+
# Create a base filter condition for non-draft projects
63+
filter_condition = Q(draft=False)
64+
65+
# If user is logged in, also include projects they own or are members of
66+
if not is_anonymous:
67+
filter_condition |= Q(owner=user) | Q(members=user)
68+
69+
return self.filter(filter_condition).distinct()
70+
71+
# For models related to Project
72+
project_accessor = model.get_project_accessor()
73+
74+
# No project relationship: return unfiltered
75+
if project_accessor is None:
76+
return self
77+
78+
# Get project field path with trailing double underscore
79+
project_field = f"{project_accessor}__"
80+
81+
# Create a base filter condition for objects related to non-draft projects
82+
filter_condition = Q(**{f"{project_field}draft": False})
83+
84+
# If user is logged in, also include objects related to projects they own or are members of
85+
if not is_anonymous:
86+
filter_condition |= Q(**{f"{project_field}owner": user}) | Q(**{f"{project_field}members": user})
87+
88+
return self.filter(filter_condition).distinct()
689

790

891
class BaseModel(models.Model):
992
""" """
1093

1194
created_at = models.DateTimeField(auto_now_add=True)
1295
updated_at = models.DateTimeField(auto_now=True)
96+
objects = BaseQuerySet.as_manager()
97+
98+
@classmethod
99+
def get_project_accessor(cls) -> str | None:
100+
"""
101+
Determines the path to access the related Project from this model.
102+
103+
This method returns the appropriate accessor path based on the model's relationship to Project:
104+
105+
1. For direct ForeignKey or OneToOneField relationships to Project (occurrence.project)
106+
- Returns "project" automatically (no need to define project_accessor)
107+
108+
2. For ManyToMany relationships to Project (pipeline.projects)
109+
- Returns "projects" automatically (no need to define project_accessor)
110+
- Note: Draft filtering will return objects with at least one non-draft project
111+
- This is appropriate for global objects (pipelines, taxa, etc.) that can belong to multiple projects
112+
- Such objects are never private data, unlike project-specific objects (occurrences, source_images)
113+
114+
3. For indirect relationships (accessed through other models) (detection.occurrence.project):
115+
- Requires explicitly defining a 'project_accessor' class attribute
116+
- Uses the Django double underscore convention ("__") to navigate through relationships
117+
- Example: "deployment__project" (not "deployment.project")
118+
where "deployment" is a field on this model and "project" is a field on Deployment
119+
120+
4. For the Project model itself:
121+
- No project_accessor needed; will be handled by the isinstance check in get_project()
122+
123+
Returns:
124+
str|None: The path to the related project, or None for no relationship or the Project model itself.
125+
"""
126+
127+
if has_one_to_many_project_relation(cls):
128+
return "project" # One-to-many or one-to-one relation
129+
130+
if has_many_to_many_project_relation(cls):
131+
return "projects" # Many-to-many relation
132+
133+
return getattr(cls, "project_accessor", None)
13134

14135
def get_project(self):
15-
"""Get the project associated with the model."""
16-
return self.project if hasattr(self, "project") else None
136+
"""Dynamically get the related project using the project_accessor."""
137+
from ami.main.models import Project
138+
139+
if isinstance(self, Project):
140+
return self
141+
142+
accessor = self.get_project_accessor()
143+
if accessor == "projects" or accessor is None:
144+
return None
145+
146+
project = self
147+
for part in accessor.split("__"):
148+
project = getattr(project, part, None)
149+
if project is None:
150+
break
151+
return project
17152

18153
def __str__(self) -> str:
19154
"""All django models should have this method."""
@@ -52,13 +187,16 @@ def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> b
52187
This method is used to determine if the user can perform
53188
CRUD operations or custom actions on the model instance.
54189
"""
190+
from ami.users.roles import BasicMember
191+
55192
project = self.get_project() if hasattr(self, "get_project") else None
56193
if not project:
57194
return False
58195
if action == "retrieve":
59-
# Allow view
196+
if project.draft:
197+
# Allow view permission for members and owners of draft projects
198+
return BasicMember.has_role(user, project) or user == project.owner or user.is_superuser
60199
return True
61-
62200
model = self._meta.model_name
63201
crud_map = {
64202
"create": f"create_{model}",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.10 on 2025-09-15 21:58
2+
3+
import ami.jobs.models
4+
from django.db import migrations
5+
import django_pydantic_field.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("jobs", "0016_job_data_export_job_params_alter_job_job_type_key"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="job",
16+
name="logs",
17+
field=django_pydantic_field.fields.PydanticSchemaField(
18+
config=None, default=ami.jobs.models.JobLogs, schema=ami.jobs.models.JobLogs
19+
),
20+
),
21+
migrations.AlterField(
22+
model_name="job",
23+
name="progress",
24+
field=django_pydantic_field.fields.PydanticSchemaField(
25+
config=None, default=ami.jobs.models.default_job_progress, schema=ami.jobs.models.JobProgress
26+
),
27+
),
28+
]

ami/main/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def save_related(self, request, form, formsets, change):
8787
"description",
8888
"priority",
8989
"active",
90+
"draft",
9091
"feature_flags",
9192
)
9293
},

ami/main/api/serializers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ class Meta:
263263
"rank",
264264
"details",
265265
"gbif_taxon_key",
266+
"fieldguide_id",
267+
"cover_image_url",
268+
"cover_image_credit",
266269
]
267270

268271

@@ -280,6 +283,7 @@ class Meta:
280283
"created_at",
281284
"updated_at",
282285
"image",
286+
"draft",
283287
]
284288

285289

@@ -581,6 +585,7 @@ class Meta:
581585
"tags",
582586
"last_detected",
583587
"best_determination_score",
588+
"cover_image_url",
584589
"created_at",
585590
"updated_at",
586591
]
@@ -807,6 +812,9 @@ class Meta:
807812
"tags",
808813
"last_detected",
809814
"best_determination_score",
815+
"fieldguide_id",
816+
"cover_image_url",
817+
"cover_image_credit",
810818
]
811819

812820

ami/main/api/views.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from rest_framework.views import APIView
2525

2626
from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
27+
from ami.base.models import BaseQuerySet
2728
from ami.base.pagination import LimitOffsetPaginationWithPermissions
2829
from ami.base.permissions import IsActiveStaffOrReadOnly, ObjectPermission
2930
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
@@ -120,6 +121,15 @@ def create(self, request, *args, **kwargs):
120121
self.perform_create(serializer)
121122
return Response(serializer.data, status=status.HTTP_201_CREATED)
122123

124+
def get_queryset(self):
125+
qs: QuerySet = super().get_queryset()
126+
assert self.queryset is not None
127+
128+
if isinstance(qs, BaseQuerySet):
129+
return qs.visible_for_user(self.request.user) # type: ignore
130+
131+
return qs
132+
123133

124134
class DefaultReadOnlyViewSet(DefaultViewSetMixin, viewsets.ReadOnlyModelViewSet):
125135
pass
@@ -1067,6 +1077,7 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
10671077
"event",
10681078
"deployment",
10691079
"determination__rank",
1080+
"detections__source_image",
10701081
]
10711082
ordering_fields = [
10721083
"created_at",
@@ -1226,6 +1237,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin):
12261237
"last_detected",
12271238
"best_determination_score",
12281239
"name",
1240+
"cover_image_url",
12291241
]
12301242
search_fields = ["name", "parent__name"]
12311243

@@ -1538,35 +1550,42 @@ class SummaryView(GenericAPIView, ProjectMixin):
15381550
@extend_schema(parameters=[project_id_doc_param])
15391551
def get(self, request):
15401552
"""
1541-
Return counts of all models.
1553+
Return counts of all models, applying visibility filters for draft projects.
15421554
"""
1555+
user = request.user
15431556
project = self.get_active_project()
15441557
if project:
15451558
data = {
1546-
"projects_count": Project.objects.count(), # @TODO filter by current user, here and everywhere!
1547-
"deployments_count": Deployment.objects.filter(project=project).count(),
1548-
"events_count": Event.objects.filter(deployment__project=project, deployment__isnull=False).count(),
1549-
"captures_count": SourceImage.objects.filter(deployment__project=project).count(),
1550-
# "detections_count": Detection.objects.filter(occurrence__project=project).count(),
1551-
"occurrences_count": Occurrence.objects.filter_by_score_threshold( # type: ignore
1552-
project, self.request
1553-
)
1554-
.valid()
1559+
"projects_count": Project.objects.visible_for_user(user).count(), # type: ignore
1560+
"deployments_count": Deployment.objects.visible_for_user(user) # type: ignore
1561+
.filter(project=project)
1562+
.count(),
1563+
"events_count": Event.objects.visible_for_user(user) # type: ignore
1564+
.filter(deployment__project=project, deployment__isnull=False)
1565+
.count(),
1566+
"captures_count": SourceImage.objects.visible_for_user(user) # type: ignore
1567+
.filter(deployment__project=project)
1568+
.count(),
1569+
"occurrences_count": Occurrence.objects.valid() # type: ignore
1570+
.visible_for_user(user)
15551571
.filter(project=project)
1556-
.count(), # type: ignore
1557-
"taxa_count": Occurrence.objects.filter_by_score_threshold(project, self.request) # type: ignore
1572+
.filter_by_score_threshold(project, self.request) # type: ignore
1573+
.count(),
1574+
"taxa_count": Occurrence.objects.visible_for_user(user) # type: ignore
1575+
.filter_by_score_threshold(project, self.request)
15581576
.unique_taxa(project=project)
1559-
.count(), # type: ignore
1577+
.count(),
15601578
}
15611579
else:
15621580
data = {
1563-
"projects_count": Project.objects.count(),
1564-
"deployments_count": Deployment.objects.count(),
1565-
"events_count": Event.objects.filter(deployment__isnull=False).count(),
1566-
"captures_count": SourceImage.objects.count(),
1567-
# "detections_count": Detection.objects.count(),
1568-
"occurrences_count": Occurrence.objects.valid().count(), # type: ignore
1569-
"taxa_count": Occurrence.objects.all().unique_taxa().count(), # type: ignore
1581+
"projects_count": Project.objects.visible_for_user(user).count(), # type: ignore
1582+
"deployments_count": Deployment.objects.visible_for_user(user).count(), # type: ignore
1583+
"events_count": Event.objects.visible_for_user(user) # type: ignore
1584+
.filter(deployment__isnull=False)
1585+
.count(),
1586+
"captures_count": SourceImage.objects.visible_for_user(user).count(), # type: ignore
1587+
"occurrences_count": Occurrence.objects.valid().visible_for_user(user).count(), # type: ignore
1588+
"taxa_count": Occurrence.objects.visible_for_user(user).unique_taxa().count(), # type: ignore
15701589
"last_updated": timezone.now(),
15711590
}
15721591

0 commit comments

Comments
 (0)