Skip to content

Commit 3528f27

Browse files
committed
Merge branch 'main' of github.com:RolnickLab/antenna into deployments/ood.antenna.insectai.org
2 parents cd56199 + 28571b0 commit 3528f27

File tree

122 files changed

+23111
-5330
lines changed

Some content is hidden

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

122 files changed

+23111
-5330
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

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

ami/base/permissions.py

Lines changed: 8 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,7 @@
66
from guardian.shortcuts import get_perms
77
from rest_framework import permissions
88

9-
from ami.jobs.models import Job
10-
from ami.main.models import (
11-
BaseModel,
12-
Deployment,
13-
Device,
14-
Project,
15-
S3StorageSource,
16-
Site,
17-
SourceImage,
18-
SourceImageCollection,
19-
SourceImageUpload,
20-
)
21-
from ami.users.roles import ProjectManager
9+
from ami.main.models import BaseModel
2210

2311
logger = logging.getLogger(__name__)
2412

@@ -64,18 +52,8 @@ def add_object_level_permissions(
6452
"""
6553

6654
permissions = response_data.get("user_permissions", set())
67-
project = instance.get_project() if hasattr(instance, "get_project") else None
68-
model_name = instance._meta.model_name # Get model name
69-
if user and user.is_superuser:
70-
permissions.update(["update", "delete"])
71-
72-
if project:
73-
user_permissions = get_perms(user, project)
74-
# Filter and extract only the action part of "action_modelname" based on instance type
75-
filtered_permissions = filter_permissions(permissions=user_permissions, model_name=model_name)
76-
# Do not return create, view permissions at object-level
77-
filtered_permissions -= {"create", "view"}
78-
permissions.update(filtered_permissions)
55+
if isinstance(instance, BaseModel):
56+
permissions.update(instance.get_user_object_permissions(user))
7957
response_data["user_permissions"] = list(permissions)
8058
return response_data
8159

@@ -99,165 +77,13 @@ def add_collection_level_permissions(user: User | None, response_data: dict, mod
9977
return response_data
10078

10179

102-
class CRUDPermission(permissions.BasePermission):
80+
class ObjectPermission(permissions.BasePermission):
10381
"""
104-
Generic CRUD permission class that dynamically checks user permissions on an object.
105-
Permission names follow the convention: `create_<model>`, `update_<model>`, `delete_<model>`.
82+
Generic permission class that delegates to the model's `check_permission(user, action)` method.
10683
"""
10784

108-
model = None
109-
11085
def has_permission(self, request, view):
111-
"""Handles general permission checks"""
112-
113-
return True # Fallback to object level permissions
114-
115-
def has_object_permission(self, request, view, obj):
116-
"""Handles object-level permission checks."""
117-
model_name = self.model._meta.model_name
118-
project = obj.get_project() if hasattr(obj, "get_project") else None
119-
# check for create action
120-
if view.action == "create":
121-
return request.user.is_superuser or request.user.has_perm(f"create_{model_name}", project)
122-
123-
if view.action == "retrieve":
124-
return True # Allow all users to view objects
125-
126-
# Map ViewSet actions to permission names
127-
action_perms = {
128-
"retrieve": f"view_{model_name}",
129-
"update": f"update_{model_name}",
130-
"partial_update": f"update_{model_name}",
131-
"destroy": f"delete_{model_name}",
132-
}
133-
134-
required_perm = action_perms.get(view.action)
135-
if not required_perm:
136-
return True
137-
return request.user.has_perm(required_perm, project)
138-
139-
140-
class ProjectCRUDPermission(CRUDPermission):
141-
model = Project
142-
143-
144-
class JobCRUDPermission(CRUDPermission):
145-
model = Job
146-
147-
148-
class DeploymentCRUDPermission(CRUDPermission):
149-
model = Deployment
150-
151-
152-
class SourceImageCollectionCRUDPermission(CRUDPermission):
153-
model = SourceImageCollection
154-
155-
156-
class SourceImageUploadCRUDPermission(CRUDPermission):
157-
model = SourceImageUpload
158-
159-
160-
class SourceImageCRUDPermission(CRUDPermission):
161-
model = SourceImage
162-
163-
164-
class CanStarSourceImage(permissions.BasePermission):
165-
"""Custom permission to check if the user can star a Source image."""
166-
167-
permission = Project.Permissions.STAR_SOURCE_IMAGE
168-
169-
def has_object_permission(self, request, view, obj):
170-
if view.action in ["unstar", "star"]:
171-
project = obj.get_project() if hasattr(obj, "get_project") else None
172-
return request.user.has_perm(self.permission, project)
173-
return True
174-
175-
176-
class S3StorageSourceCRUDPermission(CRUDPermission):
177-
model = S3StorageSource
178-
179-
180-
class SiteCRUDPermission(CRUDPermission):
181-
model = Site
182-
183-
184-
class DeviceCRUDPermission(CRUDPermission):
185-
model = Device
186-
187-
188-
# Identification permission checks
189-
class CanUpdateIdentification(permissions.BasePermission):
190-
"""Custom permission to check if the user can update/create an identification."""
191-
192-
permission = Project.Permissions.UPDATE_IDENTIFICATION
193-
194-
def has_object_permission(self, request, view, obj):
195-
if view.action in ["create", "update", "partial_update"]:
196-
project = obj.get_project() if hasattr(obj, "get_project") else None
197-
return request.user.has_perm(self.permission, project)
198-
return True
199-
200-
201-
class CanDeleteIdentification(permissions.BasePermission):
202-
"""Custom permission to check if the user can delete an identification."""
203-
204-
permission = Project.Permissions.DELETE_IDENTIFICATION
205-
206-
def has_object_permission(self, request, view, obj):
207-
project = obj.get_project() if hasattr(obj, "get_project") else None
208-
# Check if user is superuser or staff or project manager
209-
if view.action == "destroy":
210-
if request.user.is_superuser or ProjectManager.has_role(request.user, project):
211-
return True
212-
# Check if the user is the owner of the object
213-
return obj.user == request.user
214-
return True
215-
216-
217-
# Job run permission check
218-
class CanRunJob(permissions.BasePermission):
219-
"""Custom permission to check if the user can run a job."""
220-
221-
permission = Project.Permissions.RUN_JOB
222-
223-
def has_object_permission(self, request, view, obj):
224-
if view.action == "run":
225-
project = obj.get_project() if hasattr(obj, "get_project") else None
226-
return request.user.has_perm(self.permission, project)
227-
return True
228-
229-
230-
class CanRetryJob(permissions.BasePermission):
231-
"""Custom permission to check if the user can retry a job."""
232-
233-
permission = Project.Permissions.RETRY_JOB
234-
235-
def has_object_permission(self, request, view, obj):
236-
if view.action == "retry":
237-
project = obj.get_project() if hasattr(obj, "get_project") else None
238-
return request.user.has_perm(self.permission, project)
239-
return True
240-
241-
242-
class CanCancelJob(permissions.BasePermission):
243-
"""Custom permission to check if the user can cancel a job."""
244-
245-
permission = Project.Permissions.CANCEL_JOB
246-
247-
def has_object_permission(self, request, view, obj):
248-
if view.action == "cancel":
249-
project = obj.get_project() if hasattr(obj, "get_project") else None
250-
return request.user.has_perm(self.permission, project)
251-
return True
252-
253-
254-
class CanPopulateSourceImageCollection(permissions.BasePermission):
255-
"""Custom permission to check if the user can populate a collection."""
256-
257-
permission = Project.Permissions.POPULATE_COLLECTION
86+
return True # Always allow — object-level handles actual checks
25887

259-
def has_object_permission(self, request, view, obj):
260-
if view.action == "populate":
261-
project = obj.get_project() if hasattr(obj, "get_project") else None
262-
return request.user.has_perm(self.permission, project)
263-
return True
88+
def has_object_permission(self, request, view, obj: BaseModel):
89+
return obj.check_permission(request.user, view.action)

ami/base/views.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,23 @@ class ProjectMixin:
2222
def get_active_project(self) -> Project | None:
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:
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+
3238
project_id = SingleParamSerializer[int].clean(
33-
param_name="project_id",
39+
param_name=param,
3440
field=serializers.IntegerField(required=self.require_project, min_value=0),
35-
data=self.request.query_params,
41+
data={param: project_id} if project_id else {},
3642
)
3743

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

0 commit comments

Comments
 (0)