Skip to content

Commit 3330f09

Browse files
committed
Merge branch 'main' into feat/add-backend-dev-compose-override
# Conflicts: # README.md
2 parents eb38b45 + 713bb28 commit 3330f09

File tree

190 files changed

+5700
-2010
lines changed

Some content is hidden

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

190 files changed

+5700
-2010
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

.envs/.local/.django

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ MINIO_DEFAULT_BUCKET=ami
3737
MINIO_STORAGE_USE_HTTPS=False
3838
MINIO_TEST_BUCKET=ami-test
3939
MINIO_BROWSER_REDIRECT_URL=http://minio:9001
40+
41+
# Default processing service (local)
42+
DEFAULT_PROCESSING_SERVICE_NAME=Local Processing Service
43+
DEFAULT_PROCESSING_SERVICE_ENDPOINT=http://ml_backend:2000
44+
# DEFAULT_PIPELINES_ENABLED=random,constant # When set to None, all pipelines will be enabled.

.envs/.production/.django-example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,8 @@ DJANGO_ACCOUNT_ALLOW_REGISTRATION=True
5353
# Gunicorn
5454
# ------------------------------------------------------------------------------
5555
WEB_CONCURRENCY=4
56+
57+
# Default processing service
58+
DEFAULT_PROCESSING_SERVICE_NAME="AMI Data Companion"
59+
DEFAULT_PROCESSING_SERVICE_ENDPOINT=https://ml.antenna.insectai.org/
60+
DEFAULT_PIPELINES_ENABLED=global_moths_2024,quebec_vermont_moths_2023,panama_moths_2023,uk_denmark_moths_2023

.vscode/launch.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"name": "Python Debugger: Remote Attach",
6+
"type": "debugpy",
7+
"request": "attach",
8+
"connect": {
9+
"host": "localhost",
10+
"port": 5678
11+
},
12+
"pathMappings": [
13+
{
14+
"localRoot": "${workspaceFolder}",
15+
"remoteRoot": "."
16+
}
17+
]
18+
}
19+
]
20+
}

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Platform for processing and reviewing images from automated insect monitoring st
88

99
Antenna uses [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](https://docs.docker.com/compose/install/) to run all services locally for development.
1010

11-
1) Install Docker for your host operating (Linux, macOS, Windows)
11+
1) Install Docker for your host operating (Linux, macOS, Windows). Docker Compose `v2.38.2` or later recommended.
1212

1313
2) Add the following to your `/etc/hosts` file in order to see and process the demo source images. This makes the hostname `minio` and `django` alias for `localhost` so the same image URLs can be viewed in the host machine's web browser and be processed by the ML services. This can be skipped if you are using an external image storage service.
1414

@@ -25,6 +25,9 @@ Antenna uses [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](ht
2525
# To stream the logs
2626
docker compose logs -f django celeryworker ui
2727
# Ctrl+c to close the logs
28+
29+
NOTE: If you see docker build errors such as `At least one invalid signature was encountered`, these could happen if docker runs out of space. Commands like `docker image prune -f` and `docker system prune` can be helpful to clean up space.
30+
2831
```
2932
To update the UI Docker container, use the following command to rebuild the frontend and load the new changes
3033
(and remember to refresh your browser after!).
@@ -49,12 +52,15 @@ docker compose -f processing_services/example/docker-compose.yml up -d
4952
# Once running, in Antenna register a new processing service called: http://ml_backend_example:2000
5053
```
5154
52-
5) Access the platform the following URLs:
55+
5) Access the platform with the following URLs:
5356
5457
- Primary web interface: http://localhost:4000
5558
- API browser: http://localhost:8000/api/v2/
5659
- Django admin: http://localhost:8000/admin/
5760
- OpenAPI / Swagger documentation: http://localhost:8000/api/v2/docs/
61+
- Minio UI: http://minio:9001, Minio service: http://minio:9000
62+
63+
NOTE: If one of these services is not working properly, it could be due another process is using the port. You can check for this with `lsof -i :<PORT_NUMBER>`.
5864
5965
A default user will be created with the following credentials. Use these to log into the web UI or the Django admin.
6066

ami/base/filters.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.db.models import F, OrderBy
2-
from rest_framework.filters import OrderingFilter
2+
from django.forms import FloatField
3+
from rest_framework.filters import BaseFilterBackend, OrderingFilter
34

45

56
class NullsLastOrderingFilter(OrderingFilter):
@@ -8,3 +9,56 @@ def get_ordering(self, request, queryset, view):
89
if not values:
910
return values
1011
return [OrderBy(F(value.lstrip("-")), descending=value.startswith("-"), nulls_last=True) for value in values]
12+
13+
14+
class ThresholdFilter(BaseFilterBackend):
15+
"""
16+
Filter a numeric field by a minimum value.
17+
18+
Usage:
19+
20+
Filter occurrences by their determination score:
21+
GET /occurrences/?score=0.5
22+
This will return all occurrences with a determination score greater than or equal to 0.5.
23+
24+
Customize the query_param and filter_param to match your API and model fields using
25+
the create method.
26+
27+
Example:
28+
29+
DeterminationScoreFilter = ThresholdFilter.create(
30+
query_param="classification_threshold",
31+
filter_param="determination_score",
32+
)
33+
OODScoreFilter = ThresholdFilter.create("determination_ood_score")
34+
35+
class OccurrenceViewSet(DefaultViewSet):
36+
filter_backends = DefaultViewSetMixin.filter_backends + [
37+
DeterminationScoreFilter,
38+
OODScoreFilter,
39+
]
40+
"""
41+
42+
query_param = "score"
43+
filter_param = "score"
44+
45+
def filter_queryset(self, request, queryset, view):
46+
value = FloatField(required=False).clean(request.query_params.get(self.query_param))
47+
if value:
48+
filters = {f"{self.filter_param}__gte": value}
49+
queryset = queryset.filter(**filters)
50+
return queryset
51+
52+
@classmethod
53+
def create(cls, query_param: str, filter_param: str | None = None) -> type["ThresholdFilter"]:
54+
class_name = f"{cls.__name__}_{query_param}"
55+
if filter_param is None:
56+
filter_param = query_param
57+
return type(
58+
class_name,
59+
(cls,),
60+
{
61+
"query_param": query_param,
62+
"filter_param": filter_param,
63+
},
64+
)

ami/base/models.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from django.contrib.auth.models import AbstractUser, AnonymousUser
12
from django.db import models
3+
from guardian.shortcuts import get_perms
24

35
import ami.tasks
46

@@ -29,5 +31,94 @@ def update_calculated_fields(self, *args, **kwargs):
2931
"""Update calculated fields specific to each model."""
3032
pass
3133

34+
def _get_object_perms(self, user):
35+
"""
36+
Get the object-level permissions for the user on this instance.
37+
This method retrieves permissions like `update_modelname`, `create_modelname`, etc.
38+
"""
39+
project = self.get_project()
40+
if not project:
41+
return []
42+
43+
model_name = self._meta.model_name
44+
all_perms = get_perms(user, project)
45+
object_perms = [perm for perm in all_perms if perm.endswith(f"_{model_name}")]
46+
return object_perms
47+
48+
def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool:
49+
"""
50+
Check if the user has permission to perform the action
51+
on this instance.
52+
This method is used to determine if the user can perform
53+
CRUD operations or custom actions on the model instance.
54+
"""
55+
project = self.get_project() if hasattr(self, "get_project") else None
56+
if not project:
57+
return False
58+
if action == "retrieve":
59+
# Allow view
60+
return True
61+
62+
model = self._meta.model_name
63+
crud_map = {
64+
"create": f"create_{model}",
65+
"update": f"update_{model}",
66+
"partial_update": f"update_{model}",
67+
"destroy": f"delete_{model}",
68+
}
69+
70+
if action in crud_map:
71+
return user.has_perm(crud_map[action], project)
72+
73+
# Delegate to model-specific logic
74+
return self.check_custom_permission(user, action)
75+
76+
def check_custom_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool:
77+
"""Check custom permissions for the user on this instance.
78+
This is used for actions that are not standard CRUD operations.
79+
"""
80+
assert self._meta.model_name is not None, "Model must have a model_name defined in Meta class."
81+
model_name = self._meta.model_name.lower()
82+
permission_codename = f"{action}_{model_name}"
83+
project = self.get_project() if hasattr(self, "get_project") else None
84+
85+
return user.has_perm(permission_codename, project)
86+
87+
def get_user_object_permissions(self, user) -> list[str]:
88+
"""
89+
Returns a list of object-level permissions the user has on this instance.
90+
This is used by frontend to determine what actions the user can perform.
91+
"""
92+
# Return all permissions for superusers
93+
if user.is_superuser:
94+
allowed_custom_actions = self.get_custom_user_permissions(user)
95+
return ["update", "delete"] + allowed_custom_actions
96+
97+
object_perms = self._get_object_perms(user)
98+
# Check for update and delete permissions
99+
allowed_actions = set()
100+
for perm in object_perms:
101+
action = perm.split("_", 1)[0]
102+
if action in {"update", "delete"}:
103+
allowed_actions.add(action)
104+
105+
allowed_custom_actions = self.get_custom_user_permissions(user)
106+
allowed_actions.update(set(allowed_custom_actions))
107+
return list(allowed_actions)
108+
109+
def get_custom_user_permissions(self, user: AbstractUser | AnonymousUser) -> list[str]:
110+
"""
111+
Returns a list of custom permissions (not standard CRUD actions) that the user has on this instance.
112+
"""
113+
object_perms = self._get_object_perms(user)
114+
custom_perms = set()
115+
# Extract custom permissions that are not standard CRUD actions
116+
for perm in object_perms:
117+
action = perm.split("_", 1)[0]
118+
# Make sure to exclude standard CRUD actions
119+
if action not in ["view", "create", "update", "delete"]:
120+
custom_perms.add(action)
121+
return list(custom_perms)
122+
32123
class Meta:
33124
abstract = True

0 commit comments

Comments
 (0)