From 9c8a432427471b10e4e4ec2de2c1b419be678859 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:29:30 -0400 Subject: [PATCH 01/38] Moved permission checks to the BaseModel class --- ami/base/models.py | 75 +++++++++++++++++++++++++++++++++++++++++ ami/base/permissions.py | 59 +++++++++++--------------------- 2 files changed, 95 insertions(+), 39 deletions(-) diff --git a/ami/base/models.py b/ami/base/models.py index 8254a70d1..3d4437f06 100644 --- a/ami/base/models.py +++ b/ami/base/models.py @@ -1,4 +1,5 @@ from django.db import models +from guardian.shortcuts import get_perms import ami.tasks @@ -29,5 +30,79 @@ def update_calculated_fields(self, *args, **kwargs): """Update calculated fields specific to each model.""" pass + def check_permission(self, user, action: str) -> bool: + project = self.get_project() if hasattr(self, "get_project") else None + if not project: + return False + + if action == "retrieve": + # Allow view + return True + + model = self._meta.model_name + crud_map = { + "create": f"create_{model}", + "update": f"update_{model}", + "partial_update": f"update_{model}", + "destroy": f"delete_{model}", + } + + if action in crud_map: + return user.has_perm(crud_map[action], project) + + # Delegate to model-specific logic + return self.check_custom_permission(user, action) + + def check_custom_permission(self, user, action: str) -> bool: + """To be overridden in models for non-CRUD actions""" + + model_name = self._meta.model_name.lower() + permission_codename = f"{action}_{model_name}" + project = self.get_project() if hasattr(self, "get_project") else None + + return user.has_perm(permission_codename, project) + + def get_user_object_permissions(self, user) -> list[str]: + """ + Returns a list of object-level permissions the user has on this instance, + based on their role in the associated project. + """ + + project = self.get_project() + if not project: + return [] + + if user.is_superuser: + custom_perms = self.get_custom_user_permissions(user) + return ["update", "delete"] + custom_perms + allowed_perms = set() + model_name = self._meta.model_name + perms = get_perms(user, project) + # check for update and delete permissions + actions = ["update", "delete"] + for action in actions: + if f"{action}_{model_name}" in perms: + allowed_perms.add(action) + custom_perms = self.get_custom_user_permissions(user) + allowed_perms.update(set(custom_perms)) + return list(allowed_perms) + + def get_custom_user_permissions(self, user) -> list[str]: + project = self.get_project() + if not project: + return [] + + custom_perms = set() + model_name = self._meta.model_name + perms = get_perms(user, project) + for perm in perms: + # permissions are in the format "action_modelname" + if perm.endswith(f"_{model_name}"): + action = perm.split("_", 1)[0] + # make sure to exclude standard CRUD actions + if action not in ["view", "create", "update", "delete"]: + custom_perms.add(action) + return list(custom_perms) + class Meta: abstract = True diff --git a/ami/base/permissions.py b/ami/base/permissions.py index 249df0ac2..ce4b8afba 100644 --- a/ami/base/permissions.py +++ b/ami/base/permissions.py @@ -64,18 +64,8 @@ def add_object_level_permissions( """ permissions = response_data.get("user_permissions", set()) - project = instance.get_project() if hasattr(instance, "get_project") else None - model_name = instance._meta.model_name # Get model name - if user and user.is_superuser: - permissions.update(["update", "delete"]) - - if project: - user_permissions = get_perms(user, project) - # Filter and extract only the action part of "action_modelname" based on instance type - filtered_permissions = filter_permissions(permissions=user_permissions, model_name=model_name) - # Do not return create, view permissions at object-level - filtered_permissions -= {"create", "view"} - permissions.update(filtered_permissions) + if isinstance(instance, BaseModel): + permissions.update(instance.get_user_object_permissions(user)) response_data["user_permissions"] = list(permissions) return response_data @@ -216,39 +206,18 @@ def has_object_permission(self, request, view, obj): # Job run permission check class CanRunJob(permissions.BasePermission): - """Custom permission to check if the user can run a job.""" - - permission = Project.Permissions.RUN_JOB - - def has_object_permission(self, request, view, obj): - if view.action == "run": - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True + def has_object_permission(self, request, view, obj: Job): + return obj.has_permission(request.user, "run") class CanRetryJob(permissions.BasePermission): - """Custom permission to check if the user can retry a job.""" - - permission = Project.Permissions.RETRY_JOB - - def has_object_permission(self, request, view, obj): - if view.action == "retry": - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True + def has_object_permission(self, request, view, obj: Job): + return obj.has_permission(request.user, "retry") class CanCancelJob(permissions.BasePermission): - """Custom permission to check if the user can cancel a job.""" - - permission = Project.Permissions.CANCEL_JOB - - def has_object_permission(self, request, view, obj): - if view.action == "cancel": - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True + def has_object_permission(self, request, view, obj: Job): + return obj.has_permission(request.user, "cancel") class CanPopulateSourceImageCollection(permissions.BasePermission): @@ -261,3 +230,15 @@ def has_object_permission(self, request, view, obj): project = obj.get_project() if hasattr(obj, "get_project") else None return request.user.has_perm(self.permission, project) return True + + +class ObjectPermission(permissions.BasePermission): + """ + Generic permission class that delegates to the model's `check_permission(user, action)` method. + """ + + def has_permission(self, request, view): + return True # Always allow — object-level handles actual checks + + def has_object_permission(self, request, view, obj): + return obj.check_permission(request.user, view.action) From 4fdae24586430e52708b1da2cbcf15e99c15f58e Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:33:42 -0400 Subject: [PATCH 02/38] Refactor Job model permission checks to include job type for finer-grained control --- ami/jobs/models.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ami/jobs/models.py b/ami/jobs/models.py index 1166f2c74..fda199358 100644 --- a/ami/jobs/models.py +++ b/ami/jobs/models.py @@ -11,6 +11,7 @@ from django.db import models, transaction from django.utils.text import slugify from django_pydantic_field import SchemaField +from guardian.shortcuts import get_perms from ami.base.models import BaseModel from ami.base.schemas import ConfigurableStage, ConfigurableStageParam @@ -907,6 +908,34 @@ def save(self, update_progress=True, *args, **kwargs): if self.progress.summary.status != self.status: logger.warning(f"Job {self} status mismatches progress: {self.progress.summary.status} != {self.status}") + def check_custom_permission(self, user, action: str) -> bool: + job_type = self.job_type_key.lower() + if action in ["run", "cancel", "retry"]: + permission_codename = f"run_{job_type}_job" + else: + permission_codename = f"{action}_{job_type}_job" + project = self.get_project() if hasattr(self, "get_project") else None + return user.has_perm(permission_codename, project) + + def get_custom_user_permissions(self, user) -> list[str]: + project = self.get_project() + if not project: + return [] + + custom_perms = set() + model_name = "job" + perms = get_perms(user, project) + job_type = self.job_type_key.lower() + for perm in perms: + # permissions are in the format "action_modelname" + if perm.endswith(f"{job_type}_{model_name}"): + action = perm.split("_", 1)[0] + # make sure to exclude standard CRUD actions + if action not in ["view", "create", "update", "delete"]: + custom_perms.add(action) + logger.debug(f"Custom permissions for user {user} on project {self}, with jobtype {job_type}: {custom_perms}") + return list(custom_perms) + @classmethod def default_progress(cls) -> JobProgress: """Return the progress of each stage of this job as a dictionary""" From c43ef9c964629238db668b404f749cbdcb8b819c Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:34:45 -0400 Subject: [PATCH 03/38] Added fine-grained permissions for each job type --- ami/main/models.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ami/main/models.py b/ami/main/models.py index 7bd646373..903727e58 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -212,6 +212,10 @@ class Permissions: CREATE_JOB = "create_job" UPDATE_JOB = "update_job" RUN_JOB = "run_job" + RUN_ML_JOB = "run_ml_job" + RUN_POPULATE_CAPTURES_COLLECTION_JOB = "run_populate_captures_collection_job" + RUN_DATA_STORAGE_SYNC_JOB = "run_data_storage_sync_job" + RUN_DATA_EXPORT_JOB = "run_data_export_job" DELETE_JOB = "delete_job" RETRY_JOB = "retry_job" CANCEL_JOB = "cancel_job" @@ -270,6 +274,10 @@ class Meta: ("create_job", "Can create a job"), ("update_job", "Can update a job"), ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), ("delete_job", "Can delete a job"), ("retry_job", "Can retry a job"), ("cancel_job", "Can cancel a job"), @@ -1563,6 +1571,12 @@ def save(self, update_calculated_fields=True, *args, **kwargs): if update_calculated_fields: self.update_calculated_fields(save=True) + def check_custom_permission(self, user, action: str) -> bool: + if action in ["star", "unstar"]: + project = self.get_project() if hasattr(self, "get_project") else None + return user.has_perm(Project.Permissions.STAR_SOURCE_IMAGE, project) + return False + class Meta: ordering = ("deployment", "event", "timestamp") From 4407af1a32dc5d13f0330cb4a6617b0a2f7288aa Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:35:36 -0400 Subject: [PATCH 04/38] Modified roles to use the fine-grained job permissions --- ami/users/roles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ami/users/roles.py b/ami/users/roles.py index c14ad0f76..85981c672 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -75,9 +75,13 @@ class MLDataManager(Role): permissions = BasicMember.permissions | { Project.Permissions.CREATE_JOB, Project.Permissions.UPDATE_JOB, - Project.Permissions.RUN_JOB, + # Project.Permissions.RUN_JOB, Project.Permissions.RETRY_JOB, Project.Permissions.CANCEL_JOB, + # Project.Permissions.RUN_ML_JOB, + Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, + Project.Permissions.RUN_DATA_STORAGE_SYNC_JOB, + Project.Permissions.RUN_DATA_EXPORT_JOB, Project.Permissions.DELETE_JOB, Project.Permissions.DELETE_OCCURRENCES, } From 1ec1595b64d6b783e18ef6936308f636d61d9080 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:36:54 -0400 Subject: [PATCH 05/38] Updated views to use the new permission checks --- ami/jobs/views.py | 13 ++++++++++--- ami/main/api/views.py | 25 ++++++++----------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/ami/jobs/views.py b/ami/jobs/views.py index 48f288dac..09cd3e41a 100644 --- a/ami/jobs/views.py +++ b/ami/jobs/views.py @@ -7,7 +7,7 @@ from rest_framework.decorators import action from rest_framework.response import Response -from ami.base.permissions import CanCancelJob, CanRetryJob, CanRunJob, JobCRUDPermission +from ami.base.permissions import ObjectPermission from ami.base.views import ProjectMixin from ami.main.api.views import DefaultViewSet from ami.utils.fields import url_boolean_param @@ -66,7 +66,8 @@ class JobViewSet(DefaultViewSet, ProjectMixin): "source_image_collection", "pipeline", ] - permission_classes = [CanRunJob, CanRetryJob, CanCancelJob, JobCRUDPermission] + # permission_classes = [CanRunJob, CanRetryJob, CanCancelJob, JobCRUDPermission] + permission_classes = [ObjectPermission] def get_serializer_class(self): """ @@ -130,7 +131,13 @@ def perform_create(self, serializer): job: Job = serializer.save() # type: ignore if url_boolean_param(self.request, "start_now", default=False): # job.run() - job.enqueue() + # check if user has permission to run the job + if job.check_custom_permission(self.request.user, "run"): + # If the user has permission, enqueue the job + job.enqueue() + else: + # If the user does not have permission, raise an error + raise PermissionError("You do not have permission to run this job.") def get_queryset(self) -> QuerySet: jobs = super().get_queryset() diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 5ef252b3f..f51ab934d 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -27,17 +27,9 @@ from ami.base.pagination import LimitOffsetPaginationWithPermissions from ami.base.permissions import ( CanDeleteIdentification, - CanPopulateSourceImageCollection, - CanStarSourceImage, CanUpdateIdentification, - DeploymentCRUDPermission, - DeviceCRUDPermission, IsActiveStaffOrReadOnly, - ProjectCRUDPermission, - S3StorageSourceCRUDPermission, - SiteCRUDPermission, - SourceImageCollectionCRUDPermission, - SourceImageCRUDPermission, + ObjectPermission, SourceImageUploadCRUDPermission, ) from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer @@ -149,7 +141,7 @@ class ProjectViewSet(DefaultViewSet, ProjectMixin): queryset = Project.objects.filter(active=True).prefetch_related("deployments").all() serializer_class = ProjectSerializer pagination_class = ProjectPagination - permission_classes = [ProjectCRUDPermission] + permission_classes = [ObjectPermission] def get_queryset(self): qs: ProjectQuerySet = super().get_queryset() # type: ignore @@ -216,7 +208,7 @@ class DeploymentViewSet(DefaultViewSet, ProjectMixin): "last_date", ] - permission_classes = [DeploymentCRUDPermission] + permission_classes = [ObjectPermission] def get_serializer_class(self): """ @@ -479,7 +471,7 @@ class SourceImageViewSet(DefaultViewSet, ProjectMixin): "deployment__name", "event__start", ] - permission_classes = [CanStarSourceImage, SourceImageCRUDPermission] + permission_classes = [ObjectPermission] def get_serializer_class(self): """ @@ -639,8 +631,7 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin): ) serializer_class = SourceImageCollectionSerializer permission_classes = [ - CanPopulateSourceImageCollection, - SourceImageCollectionCRUDPermission, + ObjectPermission, ] filterset_fields = ["method"] ordering_fields = [ @@ -1559,7 +1550,7 @@ class SiteViewSet(DefaultViewSet, ProjectMixin): "updated_at", "name", ] - permission_classes = [SiteCRUDPermission] + permission_classes = [ObjectPermission] def get_queryset(self) -> QuerySet: query_set: QuerySet = super().get_queryset() @@ -1586,7 +1577,7 @@ class DeviceViewSet(DefaultViewSet, ProjectMixin): "updated_at", "name", ] - permission_classes = [DeviceCRUDPermission] + permission_classes = [ObjectPermission] def get_queryset(self) -> QuerySet: query_set: QuerySet = super().get_queryset() @@ -1618,7 +1609,7 @@ class StorageSourceViewSet(DefaultViewSet, ProjectMixin): "updated_at", "name", ] - permission_classes = [S3StorageSourceCRUDPermission] + permission_classes = [ObjectPermission] def get_queryset(self) -> QuerySet: query_set: QuerySet = super().get_queryset() From c4de3de4639cac205069837091b5052cbe95ca6f Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:38:15 -0400 Subject: [PATCH 06/38] Removed old job permissions from tests --- ami/main/tests.py | 55 ++++++++++++++++------------------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index 302be643d..3a262b953 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -1131,7 +1131,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": True, "update": True, "delete": True}, "site": {"create": True, "update": True, "delete": True}, "device": {"create": True, "update": True, "delete": True}, - "job": {"create": True, "update": True, "delete": True, "run": True, "retry": True, "cancel": True}, + "job": {"create": True, "update": True, "delete": True}, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1143,14 +1143,7 @@ def setUp(self) -> None: "sourceimage": {"create": False, "update": False, "delete": False}, "sourceimageupload": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": { - "create": False, - "update": False, - "delete": False, - "run": False, - "retry": False, - "cancel": False, - }, + "job": {"create": False, "update": False, "delete": False}, "identification": {"create": False, "delete": False}, "capture": {"star": True, "unstar": True}, }, @@ -1162,14 +1155,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": { - "create": False, - "update": False, - "delete": False, - "run": False, - "retry": False, - "cancel": False, - }, + "job": {"create": False, "update": False, "delete": False}, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1181,14 +1167,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": { - "create": False, - "update": False, - "delete": False, - "run": False, - "retry": False, - "cancel": False, - }, + "job": {"create": False, "update": False, "delete": False}, "identification": {"create": False, "delete": False}, "capture": {"star": False, "unstar": False}, }, @@ -1360,14 +1339,18 @@ def _test_role_permissions(self, role_class, user, permissions_map): logger.info(f"{entity} expected_status: {expected_status}, response_status:{response.status_code}") self.assertEqual(response.status_code, expected_status) - # Step 3: Test Custom Actions - if entity == "job" and entity_ids[entity]: - for action in ["run", "retry", "cancel"]: - logger.info(f"Testing {role_class} for job {action} custom permission") - if action in actions: - response = self.client.post(f"{endpoints[entity]}{entity_ids[entity]}/{action}/") - expected_status = status.HTTP_200_OK if actions[action] else status.HTTP_403_FORBIDDEN - self.assertEqual(response.status_code, expected_status) + # # Step 3: Test Custom Actions + # if entity == "job" and entity_ids[entity]: + # for action in ["run", "retry", "cancel"]: + # logger.info(f"Testing {role_class} for job {action} custom permission") + # if action in actions: + # response = self.client.post(f"{endpoints[entity]}{entity_ids[entity]}/{action}/") + # expected_status = status.HTTP_200_OK if actions[action] else status.HTTP_403_FORBIDDEN + # self.assertEqual( + # response.status_code, + # expected_status, + # f"{role_class} {action} permission failed for {entity}", + # ) if entity == "collection" and entity_ids[entity] and "populate" in actions: logger.info(f"Testing {role_class} for collection populate custom permission") @@ -1380,12 +1363,12 @@ def _test_role_permissions(self, role_class, user, permissions_map): can_star = permissions_map["capture"].get("star", False) response = self.client.post(endpoints["capture_star"]) expected_status = status.HTTP_200_OK if can_star else status.HTTP_403_FORBIDDEN - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, f"{role_class} star permission failed") logger.info(f"Testing {role_class} for capture unstar permission ") can_unstar = permissions_map["capture"].get("unstar", False) response = self.client.post(endpoints["capture_unstar"]) expected_status = status.HTTP_200_OK if can_unstar else status.HTTP_403_FORBIDDEN - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, f"{role_class} unstar permission failed") logger.info(f"{role_class}: entity_ids: {entity_ids}") # Step 5: Unassign Role and Verify Permissions are Revoked if role_class: @@ -1439,7 +1422,7 @@ def _test_role_permissions(self, role_class, user, permissions_map): response = self.client.delete(f"{endpoints[entity]}{entity_ids[entity]}/") logger.info(f"{role_class} delete response status for {entity} : {response.status_code}") expected_status = status.HTTP_204_NO_CONTENT if can_delete else status.HTTP_403_FORBIDDEN - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, f"Delete permission failed for {entity}") # try to delete the project entity = "project" From 43d7bbf49e1aef26ee308cbbb5774d8da4a349f1 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 2 Jul 2025 15:38:37 -0400 Subject: [PATCH 07/38] Added migration file --- .../migrations/0060_alter_project_options.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 ami/main/migrations/0060_alter_project_options.py diff --git a/ami/main/migrations/0060_alter_project_options.py b/ami/main/migrations/0060_alter_project_options.py new file mode 100644 index 000000000..b3e33e9f8 --- /dev/null +++ b/ami/main/migrations/0060_alter_project_options.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.10 on 2025-07-02 12:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0059_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] From b4ef1bc14bcbe11fc9515617c792319044611b66 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 09:43:41 -0400 Subject: [PATCH 08/38] Added permission for processing single source image --- ami/base/models.py | 1 - ami/jobs/views.py | 26 ++++++++++++++++++++------ ami/main/models.py | 6 +++++- ami/users/roles.py | 1 + 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/ami/base/models.py b/ami/base/models.py index 3d4437f06..7e3cda3ef 100644 --- a/ami/base/models.py +++ b/ami/base/models.py @@ -34,7 +34,6 @@ def check_permission(self, user, action: str) -> bool: project = self.get_project() if hasattr(self, "get_project") else None if not project: return False - if action == "retrieve": # Allow view return True diff --git a/ami/jobs/views.py b/ami/jobs/views.py index 09cd3e41a..5797b5d40 100644 --- a/ami/jobs/views.py +++ b/ami/jobs/views.py @@ -131,13 +131,27 @@ def perform_create(self, serializer): job: Job = serializer.save() # type: ignore if url_boolean_param(self.request, "start_now", default=False): # job.run() - # check if user has permission to run the job - if job.check_custom_permission(self.request.user, "run"): - # If the user has permission, enqueue the job - job.enqueue() + + if job.source_image_single_id: + # If the job is for a single source image, we can run it immediately + source_image = job.source_image_single + if source_image.check_permission(self.request.user, "process"): + logger.info("user has permission to process the source image") + # If the user has permission to process the source image, enqueue the job + logger.info(f"Running job {job.pk} immediately for source image {source_image.pk}") + + job.enqueue() + else: + # If the user does not have permission, raise an error + raise PermissionError("You do not have permission to process this source image.") else: - # If the user does not have permission, raise an error - raise PermissionError("You do not have permission to run this job.") + # check if user has permission to run the job + if job.check_custom_permission(self.request.user, "run"): + # If the user has permission, enqueue the job + job.enqueue() + else: + # If the user does not have permission, raise an error + raise PermissionError("You do not have permission to run this job.") def get_queryset(self) -> QuerySet: jobs = super().get_queryset() diff --git a/ami/main/models.py b/ami/main/models.py index 903727e58..7493595ae 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -236,6 +236,7 @@ class Permissions: UPDATE_SOURCE_IMAGE = "update_sourceimage" DELETE_SOURCE_IMAGE = "delete_sourceimage" STAR_SOURCE_IMAGE = "star_sourceimage" + PROCESS_SOURCE_IMAGE = "process_sourceimage" # SourceImageUpload permissions CREATE_SOURCE_IMAGE_UPLOAD = "create_sourceimageupload" @@ -295,6 +296,7 @@ class Meta: ("update_sourceimage", "Can update a source image"), ("delete_sourceimage", "Can delete a source image"), ("star_sourceimage", "Can star a source image"), + ("process_sourceimage", "Can process a single source image"), # SourceImageUpload permissions ("create_sourceimageupload", "Can create a source image upload"), ("update_sourceimageupload", "Can update a source image upload"), @@ -1572,9 +1574,11 @@ def save(self, update_calculated_fields=True, *args, **kwargs): self.update_calculated_fields(save=True) def check_custom_permission(self, user, action: str) -> bool: + project = self.get_project() if hasattr(self, "get_project") else None if action in ["star", "unstar"]: - project = self.get_project() if hasattr(self, "get_project") else None return user.has_perm(Project.Permissions.STAR_SOURCE_IMAGE, project) + elif action == "process": + return user.has_perm(Project.Permissions.PROCESS_SOURCE_IMAGE, project) return False class Meta: diff --git a/ami/users/roles.py b/ami/users/roles.py index 85981c672..bd9064aae 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -118,6 +118,7 @@ class ProjectManager(Role): Project.Permissions.CREATE_SOURCE_IMAGE, Project.Permissions.DELETE_SOURCE_IMAGE, Project.Permissions.UPDATE_SOURCE_IMAGE, + Project.Permissions.PROCESS_SOURCE_IMAGE, Project.Permissions.CREATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.UPDATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.DELETE_SOURCE_IMAGE_UPLOAD, From 095b899adbdcfccc30b525ef5851504af1b0f372 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 09:47:10 -0400 Subject: [PATCH 09/38] Changed PermissionError to PermissionDenied --- ami/jobs/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ami/jobs/views.py b/ami/jobs/views.py index 5797b5d40..2a76b5a44 100644 --- a/ami/jobs/views.py +++ b/ami/jobs/views.py @@ -5,6 +5,7 @@ from django.utils import timezone from drf_spectacular.utils import extend_schema from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from ami.base.permissions import ObjectPermission @@ -143,7 +144,7 @@ def perform_create(self, serializer): job.enqueue() else: # If the user does not have permission, raise an error - raise PermissionError("You do not have permission to process this source image.") + raise PermissionDenied("You do not have permission to process this source image.") else: # check if user has permission to run the job if job.check_custom_permission(self.request.user, "run"): @@ -151,7 +152,7 @@ def perform_create(self, serializer): job.enqueue() else: # If the user does not have permission, raise an error - raise PermissionError("You do not have permission to run this job.") + raise PermissionDenied("You do not have permission to run this job.") def get_queryset(self) -> QuerySet: jobs = super().get_queryset() From c6947069c43980f02e2c3b47dcc7166e20071d6d Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 09:47:34 -0400 Subject: [PATCH 10/38] Added migration file --- .../migrations/0061_alter_project_options.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 ami/main/migrations/0061_alter_project_options.py diff --git a/ami/main/migrations/0061_alter_project_options.py b/ami/main/migrations/0061_alter_project_options.py new file mode 100644 index 000000000..315fe5957 --- /dev/null +++ b/ami/main/migrations/0061_alter_project_options.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.10 on 2025-07-03 09:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0060_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("process_sourceimage", "Can process a single source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] From 5123aef8e4f7636253d7471cf8e5060d112e0f94 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 15:23:50 -0400 Subject: [PATCH 11/38] tests: Added tests for fine-grained job run permission --- ami/main/tests.py | 64 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index 3a262b953..523aefd9d 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -5,13 +5,13 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection, models from django.test import TestCase -from guardian.shortcuts import get_perms +from guardian.shortcuts import assign_perm, get_perms, remove_perm from PIL import Image from rest_framework import status from rest_framework.test import APIRequestFactory, APITestCase from rich import print -from ami.jobs.models import Job +from ami.jobs.models import VALID_JOB_TYPES, Job from ami.main.models import ( Deployment, Device, @@ -1555,3 +1555,63 @@ def test_project_manager_permissions_(self): self._test_sourceimageupload_permissions( user=self.project_manager, permission_map=self.PERMISSIONS_MAPS["project_manager"]["sourceimageupload"] ) + + +class TestFineGrainedJobRunPermissionTests(APITestCase): + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + email="regularuser@insectai.org", + password="password123", + ) + self.client.force_authenticate(self.user) + + self.project = Project.objects.create( + name="Job Permission Project", description="For testing job run permission" + ) + assign_perm(Project.Permissions.CREATE_JOB, self.user, self.project) + self.valid_job_keys = [cls.key for cls in VALID_JOB_TYPES if cls.key != "unknown"] + + def _create_job(self, job_type_key): + job = Job.objects.create(name="Test Job", project=self.project, job_type_key=job_type_key) + return job + + def assign_run_permission(self, key): + perm = f"main.run_{key}_job" + assign_perm(perm, self.user, self.project) + + def remove_run_permission(self, key): + perm = f"main.run_{key}_job" + remove_perm(perm, self.user, self.project) + + def test_can_only_run_permitted_job_type(self): + allowed_key = self.valid_job_keys[0] + self.assign_run_permission(allowed_key) + + for job_type_key in self.valid_job_keys: + job = self._create_job(job_type_key) + response = self.client.post(f"/api/v2/jobs/{job.pk}/run/", format="json") + if job_type_key == allowed_key: + self.assertEqual(response.status_code, status.HTTP_200_OK, f"{job_type_key} should run successfully") + else: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, f"{job_type_key} should be denied") + + def test_can_run_multiple_if_permitted(self): + allowed_keys = self.valid_job_keys[:2] + for key in allowed_keys: + self.assign_run_permission(key) + + for job_type_key in self.valid_job_keys: + job = self._create_job(job_type_key) + response = self.client.post(f"/api/v2/jobs/{job.pk}/run/", format="json") + + if job_type_key in allowed_keys: + self.assertEqual(response.status_code, status.HTTP_200_OK, f"{job_type_key} should run successfully") + else: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, f"{job_type_key} should be denied") + + def test_cannot_run_any_without_permission(self): + for job_type_key in self.valid_job_keys: + job = self._create_job(job_type_key) + response = self.client.post(f"/api/v2/jobs/{job.pk}/run/", format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, f"{job_type_key} should be denied") From 805f87bb24d5b385e2e59e9c112e8f9736d5d84b Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 15:53:34 -0400 Subject: [PATCH 12/38] Move process single source image permission from source image to job model --- ami/jobs/models.py | 4 +++- ami/jobs/views.py | 26 +++++--------------------- ami/main/models.py | 7 ++----- ami/users/roles.py | 2 +- 4 files changed, 11 insertions(+), 28 deletions(-) diff --git a/ami/jobs/models.py b/ami/jobs/models.py index fda199358..69629e72a 100644 --- a/ami/jobs/models.py +++ b/ami/jobs/models.py @@ -910,6 +910,8 @@ def save(self, update_progress=True, *args, **kwargs): def check_custom_permission(self, user, action: str) -> bool: job_type = self.job_type_key.lower() + if self.source_image_single_id: + action = "process_single_image" if action in ["run", "cancel", "retry"]: permission_codename = f"run_{job_type}_job" else: @@ -929,7 +931,7 @@ def get_custom_user_permissions(self, user) -> list[str]: for perm in perms: # permissions are in the format "action_modelname" if perm.endswith(f"{job_type}_{model_name}"): - action = perm.split("_", 1)[0] + action = perm[: -len(f"_{job_type}_{model_name}")] # make sure to exclude standard CRUD actions if action not in ["view", "create", "update", "delete"]: custom_perms.add(action) diff --git a/ami/jobs/views.py b/ami/jobs/views.py index 2a76b5a44..6bc321f3e 100644 --- a/ami/jobs/views.py +++ b/ami/jobs/views.py @@ -131,28 +131,12 @@ def perform_create(self, serializer): job: Job = serializer.save() # type: ignore if url_boolean_param(self.request, "start_now", default=False): - # job.run() - - if job.source_image_single_id: - # If the job is for a single source image, we can run it immediately - source_image = job.source_image_single - if source_image.check_permission(self.request.user, "process"): - logger.info("user has permission to process the source image") - # If the user has permission to process the source image, enqueue the job - logger.info(f"Running job {job.pk} immediately for source image {source_image.pk}") - - job.enqueue() - else: - # If the user does not have permission, raise an error - raise PermissionDenied("You do not have permission to process this source image.") + if job.check_custom_permission(self.request.user, "run"): + # If the user has permission, enqueue the job + job.enqueue() else: - # check if user has permission to run the job - if job.check_custom_permission(self.request.user, "run"): - # If the user has permission, enqueue the job - job.enqueue() - else: - # If the user does not have permission, raise an error - raise PermissionDenied("You do not have permission to run this job.") + # If the user does not have permission, raise an error + raise PermissionDenied("You do not have permission to run this job.") def get_queryset(self) -> QuerySet: jobs = super().get_queryset() diff --git a/ami/main/models.py b/ami/main/models.py index 7493595ae..420abc7ef 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -213,6 +213,7 @@ class Permissions: UPDATE_JOB = "update_job" RUN_JOB = "run_job" RUN_ML_JOB = "run_ml_job" + PROCESS_SINGLE_IMAGE_JOB = "process_single_image_ml_job" RUN_POPULATE_CAPTURES_COLLECTION_JOB = "run_populate_captures_collection_job" RUN_DATA_STORAGE_SYNC_JOB = "run_data_storage_sync_job" RUN_DATA_EXPORT_JOB = "run_data_export_job" @@ -236,7 +237,6 @@ class Permissions: UPDATE_SOURCE_IMAGE = "update_sourceimage" DELETE_SOURCE_IMAGE = "delete_sourceimage" STAR_SOURCE_IMAGE = "star_sourceimage" - PROCESS_SOURCE_IMAGE = "process_sourceimage" # SourceImageUpload permissions CREATE_SOURCE_IMAGE_UPLOAD = "create_sourceimageupload" @@ -279,6 +279,7 @@ class Meta: ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("process_single_image_ml_job", "Can process a single capture"), ("delete_job", "Can delete a job"), ("retry_job", "Can retry a job"), ("cancel_job", "Can cancel a job"), @@ -296,7 +297,6 @@ class Meta: ("update_sourceimage", "Can update a source image"), ("delete_sourceimage", "Can delete a source image"), ("star_sourceimage", "Can star a source image"), - ("process_sourceimage", "Can process a single source image"), # SourceImageUpload permissions ("create_sourceimageupload", "Can create a source image upload"), ("update_sourceimageupload", "Can update a source image upload"), @@ -1577,9 +1577,6 @@ def check_custom_permission(self, user, action: str) -> bool: project = self.get_project() if hasattr(self, "get_project") else None if action in ["star", "unstar"]: return user.has_perm(Project.Permissions.STAR_SOURCE_IMAGE, project) - elif action == "process": - return user.has_perm(Project.Permissions.PROCESS_SOURCE_IMAGE, project) - return False class Meta: ordering = ("deployment", "event", "timestamp") diff --git a/ami/users/roles.py b/ami/users/roles.py index bd9064aae..dadf74d3f 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -118,7 +118,7 @@ class ProjectManager(Role): Project.Permissions.CREATE_SOURCE_IMAGE, Project.Permissions.DELETE_SOURCE_IMAGE, Project.Permissions.UPDATE_SOURCE_IMAGE, - Project.Permissions.PROCESS_SOURCE_IMAGE, + Project.Permissions.PROCESS_SINGLE_IMAGE_JOB, Project.Permissions.CREATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.UPDATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.DELETE_SOURCE_IMAGE_UPLOAD, From e995d9d130611a09045389c16a267bff4fcc9a8b Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 15:54:05 -0400 Subject: [PATCH 13/38] Added migration files --- .../migrations/0062_alter_project_options.py | 60 +++++++++++++++++++ .../migrations/0063_alter_project_options.py | 60 +++++++++++++++++++ .../migrations/0064_alter_project_options.py | 59 ++++++++++++++++++ .../migrations/0065_alter_project_options.py | 59 ++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 ami/main/migrations/0062_alter_project_options.py create mode 100644 ami/main/migrations/0063_alter_project_options.py create mode 100644 ami/main/migrations/0064_alter_project_options.py create mode 100644 ami/main/migrations/0065_alter_project_options.py diff --git a/ami/main/migrations/0062_alter_project_options.py b/ami/main/migrations/0062_alter_project_options.py new file mode 100644 index 000000000..edcfc3f9c --- /dev/null +++ b/ami/main/migrations/0062_alter_project_options.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.10 on 2025-07-03 15:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0061_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("process_single_image_job", "Can process a single source image"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("process_sourceimage", "Can process a single source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] diff --git a/ami/main/migrations/0063_alter_project_options.py b/ami/main/migrations/0063_alter_project_options.py new file mode 100644 index 000000000..aa3a86149 --- /dev/null +++ b/ami/main/migrations/0063_alter_project_options.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.10 on 2025-07-03 15:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0062_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("process_single_image_ml_job", "Can process a single source image"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("process_sourceimage", "Can process a single source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] diff --git a/ami/main/migrations/0064_alter_project_options.py b/ami/main/migrations/0064_alter_project_options.py new file mode 100644 index 000000000..580dd7c16 --- /dev/null +++ b/ami/main/migrations/0064_alter_project_options.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.10 on 2025-07-03 15:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0063_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("process_single_image_ml_job", "Can process a single source image"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] diff --git a/ami/main/migrations/0065_alter_project_options.py b/ami/main/migrations/0065_alter_project_options.py new file mode 100644 index 000000000..3f7e0fc72 --- /dev/null +++ b/ami/main/migrations/0065_alter_project_options.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.10 on 2025-07-03 15:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0064_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("process_single_image_ml_job", "Can process a single capture"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] From cdbae94dc2c66de926be2671285aee4328c8ce45 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 3 Jul 2025 16:23:28 -0400 Subject: [PATCH 14/38] Remove process_sourceimage permission from job details response --- ami/jobs/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ami/jobs/models.py b/ami/jobs/models.py index 69629e72a..156092ecd 100644 --- a/ami/jobs/models.py +++ b/ami/jobs/models.py @@ -933,7 +933,7 @@ def get_custom_user_permissions(self, user) -> list[str]: if perm.endswith(f"{job_type}_{model_name}"): action = perm[: -len(f"_{job_type}_{model_name}")] # make sure to exclude standard CRUD actions - if action not in ["view", "create", "update", "delete"]: + if action not in ["view", "create", "update", "delete", "process_single_image"]: custom_perms.add(action) logger.debug(f"Custom permissions for user {user} on project {self}, with jobtype {job_type}: {custom_perms}") return list(custom_perms) From 22882a68bae8073233f1dfd2c3c04290deb6341f Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Fri, 4 Jul 2025 02:24:01 -0400 Subject: [PATCH 15/38] Added type hints --- ami/base/models.py | 10 ++++++---- ami/base/permissions.py | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ami/base/models.py b/ami/base/models.py index 7e3cda3ef..bab1444c2 100644 --- a/ami/base/models.py +++ b/ami/base/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import AbstractUser, AnonymousUser from django.db import models from guardian.shortcuts import get_perms @@ -30,7 +31,7 @@ def update_calculated_fields(self, *args, **kwargs): """Update calculated fields specific to each model.""" pass - def check_permission(self, user, action: str) -> bool: + def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: project = self.get_project() if hasattr(self, "get_project") else None if not project: return False @@ -52,9 +53,9 @@ def check_permission(self, user, action: str) -> bool: # Delegate to model-specific logic return self.check_custom_permission(user, action) - def check_custom_permission(self, user, action: str) -> bool: + def check_custom_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: """To be overridden in models for non-CRUD actions""" - + assert self._meta.model_name is not None, "Model must have a model_name defined in Meta class." model_name = self._meta.model_name.lower() permission_codename = f"{action}_{model_name}" project = self.get_project() if hasattr(self, "get_project") else None @@ -86,7 +87,7 @@ def get_user_object_permissions(self, user) -> list[str]: allowed_perms.update(set(custom_perms)) return list(allowed_perms) - def get_custom_user_permissions(self, user) -> list[str]: + def get_custom_user_permissions(self, user: AbstractUser | AnonymousUser) -> list[str]: project = self.get_project() if not project: return [] @@ -97,6 +98,7 @@ def get_custom_user_permissions(self, user) -> list[str]: for perm in perms: # permissions are in the format "action_modelname" if perm.endswith(f"_{model_name}"): + # process_single_image_sourceimage action = perm.split("_", 1)[0] # make sure to exclude standard CRUD actions if action not in ["view", "create", "update", "delete"]: diff --git a/ami/base/permissions.py b/ami/base/permissions.py index ce4b8afba..38f4c811a 100644 --- a/ami/base/permissions.py +++ b/ami/base/permissions.py @@ -7,6 +7,8 @@ from rest_framework import permissions from ami.jobs.models import Job + +# from ami.main.api.views import DefaultViewSet from ami.main.models import ( BaseModel, Deployment, @@ -20,6 +22,9 @@ ) from ami.users.roles import ProjectManager +# from rest_framework.viewsets import ModelViewSet + + logger = logging.getLogger(__name__) @@ -240,5 +245,5 @@ class ObjectPermission(permissions.BasePermission): def has_permission(self, request, view): return True # Always allow — object-level handles actual checks - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj: BaseModel): return obj.check_permission(request.user, view.action) From a7eab7abb1efe2a1c7484b02f9e65defb18a259e Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Fri, 4 Jul 2025 02:25:04 -0400 Subject: [PATCH 16/38] Modified process single image permission name --- ami/jobs/models.py | 7 ++++--- ami/main/api/views.py | 1 + ami/main/models.py | 28 +++++++++++++++++++++++----- ami/users/roles.py | 5 +---- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/ami/jobs/models.py b/ami/jobs/models.py index 156092ecd..aa3215068 100644 --- a/ami/jobs/models.py +++ b/ami/jobs/models.py @@ -910,12 +910,13 @@ def save(self, update_progress=True, *args, **kwargs): def check_custom_permission(self, user, action: str) -> bool: job_type = self.job_type_key.lower() - if self.source_image_single_id: - action = "process_single_image" + if self.source_image_single: + action = "run_single_image" if action in ["run", "cancel", "retry"]: permission_codename = f"run_{job_type}_job" else: permission_codename = f"{action}_{job_type}_job" + project = self.get_project() if hasattr(self, "get_project") else None return user.has_perm(permission_codename, project) @@ -933,7 +934,7 @@ def get_custom_user_permissions(self, user) -> list[str]: if perm.endswith(f"{job_type}_{model_name}"): action = perm[: -len(f"_{job_type}_{model_name}")] # make sure to exclude standard CRUD actions - if action not in ["view", "create", "update", "delete", "process_single_image"]: + if action not in ["view", "create", "update", "delete"]: custom_perms.add(action) logger.debug(f"Custom permissions for user {user} on project {self}, with jobtype {job_type}: {custom_perms}") return list(custom_perms) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index ac2848487..9572f77c9 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1548,6 +1548,7 @@ class IdentificationViewSet(DefaultViewSet): "user", ] permission_classes = [CanUpdateIdentification, CanDeleteIdentification] + # permission_classes = [ObjectPermission] def perform_create(self, serializer): """ diff --git a/ami/main/models.py b/ami/main/models.py index 420abc7ef..ec3189b39 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -23,6 +23,7 @@ from django.template.defaultfilters import filesizeformat from django.utils import timezone from django_pydantic_field import SchemaField +from guardian.shortcuts import get_perms import ami.tasks import ami.utils @@ -211,15 +212,12 @@ class Permissions: # Job permissions CREATE_JOB = "create_job" UPDATE_JOB = "update_job" - RUN_JOB = "run_job" RUN_ML_JOB = "run_ml_job" - PROCESS_SINGLE_IMAGE_JOB = "process_single_image_ml_job" + RUN_SINGLE_IMAGE_JOB = "run_single_image_ml_job" RUN_POPULATE_CAPTURES_COLLECTION_JOB = "run_populate_captures_collection_job" RUN_DATA_STORAGE_SYNC_JOB = "run_data_storage_sync_job" RUN_DATA_EXPORT_JOB = "run_data_export_job" DELETE_JOB = "delete_job" - RETRY_JOB = "retry_job" - CANCEL_JOB = "cancel_job" # Deployment permissions CREATE_DEPLOYMENT = "create_deployment" @@ -279,7 +277,7 @@ class Meta: ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("process_single_image_ml_job", "Can process a single capture"), + ("run_single_image_ml_job", "Can process a single capture"), ("delete_job", "Can delete a job"), ("retry_job", "Can retry a job"), ("cancel_job", "Can cancel a job"), @@ -1578,6 +1576,26 @@ def check_custom_permission(self, user, action: str) -> bool: if action in ["star", "unstar"]: return user.has_perm(Project.Permissions.STAR_SOURCE_IMAGE, project) + def get_custom_user_permissions(self, user) -> list[str]: + project = self.get_project() + if not project: + return [] + + custom_perms = set() + perms = get_perms(user, project) + logger.info(f"User {user} has permissions: {perms} for project {project} on SourceImage {self.pk}") + for perm in perms: + # permissions are in the format "action_modelname" + if perm.endswith("_sourceimage"): + # process_single_image_sourceimage + action = perm.split("_", 1)[0] + # make sure to exclude standard CRUD actions + if action not in ["view", "create", "update", "delete"]: + custom_perms.add(action) + if Project.Permissions.RUN_SINGLE_IMAGE_JOB in perms: + custom_perms.add("run_single_image") + return list(custom_perms) + class Meta: ordering = ("deployment", "event", "timestamp") diff --git a/ami/users/roles.py b/ami/users/roles.py index dadf74d3f..b8add17b9 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -56,6 +56,7 @@ class BasicMember(Role): permissions = Role.permissions | { Project.Permissions.VIEW_PRIVATE_DATA, Project.Permissions.STAR_SOURCE_IMAGE, + Project.Permissions.RUN_SINGLE_IMAGE_JOB, } @@ -75,9 +76,6 @@ class MLDataManager(Role): permissions = BasicMember.permissions | { Project.Permissions.CREATE_JOB, Project.Permissions.UPDATE_JOB, - # Project.Permissions.RUN_JOB, - Project.Permissions.RETRY_JOB, - Project.Permissions.CANCEL_JOB, # Project.Permissions.RUN_ML_JOB, Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, Project.Permissions.RUN_DATA_STORAGE_SYNC_JOB, @@ -118,7 +116,6 @@ class ProjectManager(Role): Project.Permissions.CREATE_SOURCE_IMAGE, Project.Permissions.DELETE_SOURCE_IMAGE, Project.Permissions.UPDATE_SOURCE_IMAGE, - Project.Permissions.PROCESS_SINGLE_IMAGE_JOB, Project.Permissions.CREATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.UPDATE_SOURCE_IMAGE_UPLOAD, Project.Permissions.DELETE_SOURCE_IMAGE_UPLOAD, From 4ee7e53737afec0afd2fb12eca51a9d06fed7160 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Fri, 4 Jul 2025 02:30:44 -0400 Subject: [PATCH 17/38] Added migration file --- .../migrations/0066_alter_project_options.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 ami/main/migrations/0066_alter_project_options.py diff --git a/ami/main/migrations/0066_alter_project_options.py b/ami/main/migrations/0066_alter_project_options.py new file mode 100644 index 000000000..c8758eeab --- /dev/null +++ b/ami/main/migrations/0066_alter_project_options.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.10 on 2025-07-04 02:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0065_alter_project_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_job", "Can run a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("run_single_image_ml_job", "Can process a single capture"), + ("delete_job", "Can delete a job"), + ("retry_job", "Can retry a job"), + ("cancel_job", "Can cancel a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_private_data", "Can view private data"), + ("trigger_exports", "Can trigger data exports"), + ], + }, + ), + ] From 7adf27d32a66a771b6e9cf9dcbec28cbbb4f5822 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Fri, 4 Jul 2025 02:53:14 -0400 Subject: [PATCH 18/38] Changed tests to check for run single image permission in the job details response --- ami/jobs/tests.py | 2 +- ami/main/models.py | 1 - ami/main/tests.py | 16 ++++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ami/jobs/tests.py b/ami/jobs/tests.py index 6623141f3..64fbf23a2 100644 --- a/ami/jobs/tests.py +++ b/ami/jobs/tests.py @@ -155,7 +155,7 @@ def test_run_job(self): self.client.force_authenticate(user=self.user) # give user run permission - assign_perm(Project.Permissions.RUN_JOB, self.user, self.project) + assign_perm(Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, self.user, self.project) resp = self.client.post(jobs_run_url) self.assertEqual(resp.status_code, 200) diff --git a/ami/main/models.py b/ami/main/models.py index ec3189b39..2f6ad3bb9 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1583,7 +1583,6 @@ def get_custom_user_permissions(self, user) -> list[str]: custom_perms = set() perms = get_perms(user, project) - logger.info(f"User {user} has permissions: {perms} for project {project} on SourceImage {self.pk}") for perm in perms: # permissions are in the format "action_modelname" if perm.endswith("_sourceimage"): diff --git a/ami/main/tests.py b/ami/main/tests.py index 523aefd9d..a25c42b4a 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -1131,7 +1131,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": True, "update": True, "delete": True}, "site": {"create": True, "update": True, "delete": True}, "device": {"create": True, "update": True, "delete": True}, - "job": {"create": True, "update": True, "delete": True}, + "job": {"create": True, "update": True, "delete": True, "run_single_image": True}, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1143,7 +1143,7 @@ def setUp(self) -> None: "sourceimage": {"create": False, "update": False, "delete": False}, "sourceimageupload": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False}, + "job": {"create": False, "update": False, "delete": False, "run_single_image": True}, "identification": {"create": False, "delete": False}, "capture": {"star": True, "unstar": True}, }, @@ -1155,7 +1155,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False}, + "job": {"create": False, "update": False, "delete": False, "run_single_image": True}, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1167,7 +1167,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False}, + "job": {"create": False, "update": False, "delete": False, "run_single_image": False}, "identification": {"create": False, "delete": False}, "capture": {"star": False, "unstar": False}, }, @@ -1192,7 +1192,7 @@ def _create_project(self, owner): ) def _create_job(self): - self.job = Job.objects.create(name="Test Job", project=self.project) + self.job = Job.objects.create(name="Test Job", project=self.project, job_type_key="ml") def _create_source_image_upload_file(self): image_buffer = BytesIO() @@ -1322,7 +1322,11 @@ def _test_role_permissions(self, role_class, user, permissions_map): object_id = entity_ids[entity] obj = next((r for r in results if r["id"] == object_id), None) if obj: - self.assertEqual(set(obj.get("user_permissions", [])), set(object_permissions)) + self.assertEqual( + set(obj.get("user_permissions", [])), + set(object_permissions), + f"Object permissions mismatch for {entity}", + ) # Step 2: Test Update logger.info(f"Testing {role_class} update permission for {entity} , actions {actions}") From 9642ab7a279e0ce61c62b9d24808b7b50f3e46d9 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 4 Jul 2025 13:51:21 +0200 Subject: [PATCH 19/38] chore: update FE permission handling for processing single captures --- ui/src/data-services/models/capture-details.ts | 5 +++++ .../capture-details/capture-job/process-now.tsx | 10 ++++++---- ui/src/utils/user/types.ts | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/src/data-services/models/capture-details.ts b/ui/src/data-services/models/capture-details.ts index b88b3f796..c1c83c301 100644 --- a/ui/src/data-services/models/capture-details.ts +++ b/ui/src/data-services/models/capture-details.ts @@ -1,3 +1,4 @@ +import { UserPermission } from 'utils/user/types' import { Capture, ServerCapture } from './capture' import { Job } from './job' @@ -69,4 +70,8 @@ export class CaptureDetails extends Capture { get url(): string { return this._capture.url } + + get userPermissions(): UserPermission[] { + return this._capture.user_permissions + } } diff --git a/ui/src/pages/session-details/playback/capture-details/capture-job/process-now.tsx b/ui/src/pages/session-details/playback/capture-details/capture-job/process-now.tsx index dca941780..3ccbc885b 100644 --- a/ui/src/pages/session-details/playback/capture-details/capture-job/process-now.tsx +++ b/ui/src/pages/session-details/playback/capture-details/capture-job/process-now.tsx @@ -1,11 +1,11 @@ import { useCreateJob } from 'data-services/hooks/jobs/useCreateJob' -import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' import { CaptureDetails } from 'data-services/models/capture-details' import { Tooltip } from 'design-system/components/tooltip/tooltip' import { CheckIcon, Loader2Icon } from 'lucide-react' import { Button } from 'nova-ui-kit' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' +import { UserPermission } from 'utils/user/types' export const ProcessNow = ({ capture, @@ -15,14 +15,16 @@ export const ProcessNow = ({ pipelineId?: string }) => { const { projectId } = useParams() - const { project } = useProjectDetails(projectId as string, true) const { createJob, isLoading, isSuccess } = useCreateJob() + const canProcess = capture?.userPermissions.includes( + UserPermission.RunSingleImage + ) const disabled = - !capture || capture.hasJobInProgress || !pipelineId || !project?.canUpdate + !capture || capture.hasJobInProgress || !pipelineId || !canProcess // @TODO: hasJobInProgress, replace with if pipeline is healthy/available - const tooltipContent = project?.canUpdate + const tooltipContent = canProcess ? translate(STRING.MESSAGE_PROCESS_NOW_TOOLTIP) : translate(STRING.MESSAGE_PERMISSIONS_MISSING) diff --git a/ui/src/utils/user/types.ts b/ui/src/utils/user/types.ts index 35fa199a7..9761b49b5 100644 --- a/ui/src/utils/user/types.ts +++ b/ui/src/utils/user/types.ts @@ -17,6 +17,7 @@ export enum UserPermission { Populate = 'populate', // Custom collection permission Retry = 'retry', // Custom job permission Run = 'run', // Custom job permission + RunSingleImage = 'run_single_image', // Custom job permission Star = 'star', Update = 'update', // Custom capture permission } From b4e16ad636be4cf493877f45a58bb2489da2dbd8 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 4 Jul 2025 13:52:50 +0200 Subject: [PATCH 20/38] chore: update FE permission handling to use single run permission --- ui/src/data-services/models/job.ts | 4 ++-- ui/src/utils/user/types.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/src/data-services/models/job.ts b/ui/src/data-services/models/job.ts index 120d1efb1..625db4ec7 100644 --- a/ui/src/data-services/models/job.ts +++ b/ui/src/data-services/models/job.ts @@ -45,7 +45,7 @@ export class Job { get canCancel(): boolean { return ( - this._job.user_permissions.includes(UserPermission.Cancel) && + this._job.user_permissions.includes(UserPermission.Run) && (this.status.code === 'STARTED' || this.status.code === 'PENDING') ) } @@ -63,7 +63,7 @@ export class Job { get canRetry(): boolean { return ( - this._job.user_permissions.includes(UserPermission.Retry) && + this._job.user_permissions.includes(UserPermission.Run) && this.status.code !== 'CREATED' && this.status.code !== 'STARTED' && this.status.code !== 'PENDING' diff --git a/ui/src/utils/user/types.ts b/ui/src/utils/user/types.ts index 9761b49b5..876d1603a 100644 --- a/ui/src/utils/user/types.ts +++ b/ui/src/utils/user/types.ts @@ -11,15 +11,13 @@ export type UserInfo = { } export enum UserPermission { - Cancel = 'cancel', // Custom job permission Create = 'create', Delete = 'delete', Populate = 'populate', // Custom collection permission - Retry = 'retry', // Custom job permission Run = 'run', // Custom job permission RunSingleImage = 'run_single_image', // Custom job permission Star = 'star', - Update = 'update', // Custom capture permission + Update = 'update', } export interface UserContextValues { From f385af3eb424ef16435be418d61e48132bb6b50b Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 10:43:44 -0400 Subject: [PATCH 21/38] Squashed migrations --- .../migrations/0058_alter_project_options.py | 48 --------------- ...ns_squashed_0067_alter_project_options.py} | 20 +++++-- .../migrations/0059_alter_project_options.py | 54 ----------------- .../migrations/0060_alter_project_options.py | 58 ------------------ .../migrations/0061_alter_project_options.py | 59 ------------------ .../migrations/0062_alter_project_options.py | 60 ------------------- .../migrations/0063_alter_project_options.py | 60 ------------------- .../migrations/0064_alter_project_options.py | 59 ------------------ .../migrations/0065_alter_project_options.py | 59 ------------------ 9 files changed, 15 insertions(+), 462 deletions(-) delete mode 100644 ami/main/migrations/0058_alter_project_options.py rename ami/main/migrations/{0066_alter_project_options.py => 0058_alter_project_options_squashed_0067_alter_project_options.py} (84%) delete mode 100644 ami/main/migrations/0059_alter_project_options.py delete mode 100644 ami/main/migrations/0060_alter_project_options.py delete mode 100644 ami/main/migrations/0061_alter_project_options.py delete mode 100644 ami/main/migrations/0062_alter_project_options.py delete mode 100644 ami/main/migrations/0063_alter_project_options.py delete mode 100644 ami/main/migrations/0064_alter_project_options.py delete mode 100644 ami/main/migrations/0065_alter_project_options.py diff --git a/ami/main/migrations/0058_alter_project_options.py b/ami/main/migrations/0058_alter_project_options.py deleted file mode 100644 index d9c15a410..000000000 --- a/ami/main/migrations/0058_alter_project_options.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.2.10 on 2025-02-22 19:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0057_merge_20250220_0022"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("star_sourceimage", "Can star a source image"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0066_alter_project_options.py b/ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py similarity index 84% rename from ami/main/migrations/0066_alter_project_options.py rename to ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py index c8758eeab..e425d83ab 100644 --- a/ami/main/migrations/0066_alter_project_options.py +++ b/ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py @@ -1,11 +1,24 @@ -# Generated by Django 4.2.10 on 2025-07-04 02:06 +# Generated by Django 4.2.10 on 2025-07-07 10:41 from django.db import migrations class Migration(migrations.Migration): - dependencies = [ + replaces = [ + ("main", "0058_alter_project_options"), + ("main", "0059_alter_project_options"), + ("main", "0060_alter_project_options"), + ("main", "0061_alter_project_options"), + ("main", "0062_alter_project_options"), + ("main", "0063_alter_project_options"), + ("main", "0064_alter_project_options"), ("main", "0065_alter_project_options"), + ("main", "0066_alter_project_options"), + ("main", "0067_alter_project_options"), + ] + + dependencies = [ + ("main", "0057_merge_20250220_0022"), ] operations = [ @@ -19,15 +32,12 @@ class Migration(migrations.Migration): ("delete_identification", "Can delete identifications"), ("create_job", "Can create a job"), ("update_job", "Can update a job"), - ("run_job", "Can run a job"), ("run_ml_job", "Can run/retry/cancel ML jobs"), ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), ("run_single_image_ml_job", "Can process a single capture"), ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), ("create_deployment", "Can create a deployment"), ("delete_deployment", "Can delete a deployment"), ("update_deployment", "Can update a deployment"), diff --git a/ami/main/migrations/0059_alter_project_options.py b/ami/main/migrations/0059_alter_project_options.py deleted file mode 100644 index 8cfd9c6f9..000000000 --- a/ami/main/migrations/0059_alter_project_options.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 4.2.10 on 2025-04-02 10:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0058_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0060_alter_project_options.py b/ami/main/migrations/0060_alter_project_options.py deleted file mode 100644 index b3e33e9f8..000000000 --- a/ami/main/migrations/0060_alter_project_options.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-02 12:11 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0059_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0061_alter_project_options.py b/ami/main/migrations/0061_alter_project_options.py deleted file mode 100644 index 315fe5957..000000000 --- a/ami/main/migrations/0061_alter_project_options.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-03 09:00 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0060_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("process_sourceimage", "Can process a single source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0062_alter_project_options.py b/ami/main/migrations/0062_alter_project_options.py deleted file mode 100644 index edcfc3f9c..000000000 --- a/ami/main/migrations/0062_alter_project_options.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-03 15:38 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0061_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("process_single_image_job", "Can process a single source image"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("process_sourceimage", "Can process a single source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0063_alter_project_options.py b/ami/main/migrations/0063_alter_project_options.py deleted file mode 100644 index aa3a86149..000000000 --- a/ami/main/migrations/0063_alter_project_options.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-03 15:39 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0062_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("process_single_image_ml_job", "Can process a single source image"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("process_sourceimage", "Can process a single source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0064_alter_project_options.py b/ami/main/migrations/0064_alter_project_options.py deleted file mode 100644 index 580dd7c16..000000000 --- a/ami/main/migrations/0064_alter_project_options.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-03 15:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0063_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("process_single_image_ml_job", "Can process a single source image"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] diff --git a/ami/main/migrations/0065_alter_project_options.py b/ami/main/migrations/0065_alter_project_options.py deleted file mode 100644 index 3f7e0fc72..000000000 --- a/ami/main/migrations/0065_alter_project_options.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-03 15:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0064_alter_project_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="project", - options={ - "ordering": ["-priority", "created_at"], - "permissions": [ - ("create_identification", "Can create identifications"), - ("update_identification", "Can update identifications"), - ("delete_identification", "Can delete identifications"), - ("create_job", "Can create a job"), - ("update_job", "Can update a job"), - ("run_job", "Can run a job"), - ("run_ml_job", "Can run/retry/cancel ML jobs"), - ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), - ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), - ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), - ("process_single_image_ml_job", "Can process a single capture"), - ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), - ("create_deployment", "Can create a deployment"), - ("delete_deployment", "Can delete a deployment"), - ("update_deployment", "Can update a deployment"), - ("create_sourceimagecollection", "Can create a collection"), - ("update_sourceimagecollection", "Can update a collection"), - ("delete_sourceimagecollection", "Can delete a collection"), - ("populate_sourceimagecollection", "Can populate a collection"), - ("create_sourceimage", "Can create a source image"), - ("update_sourceimage", "Can update a source image"), - ("delete_sourceimage", "Can delete a source image"), - ("star_sourceimage", "Can star a source image"), - ("create_sourceimageupload", "Can create a source image upload"), - ("update_sourceimageupload", "Can update a source image upload"), - ("delete_sourceimageupload", "Can delete a source image upload"), - ("create_s3storagesource", "Can create storage"), - ("delete_s3storagesource", "Can delete storage"), - ("update_s3storagesource", "Can update storage"), - ("create_site", "Can create a site"), - ("delete_site", "Can delete a site"), - ("update_site", "Can update a site"), - ("create_device", "Can create a device"), - ("delete_device", "Can delete a device"), - ("update_device", "Can update a device"), - ("view_private_data", "Can view private data"), - ("trigger_exports", "Can trigger data exports"), - ], - }, - ), - ] From a15e9cf07325f7e20de5a74f7a55f2511791a853 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:19:24 -0400 Subject: [PATCH 22/38] Cleaned up permission check classes defined for each model and used a generic permission check instead --- ami/base/permissions.py | 162 +--------------------------------------- ami/jobs/views.py | 2 +- ami/main/api/views.py | 14 +--- 3 files changed, 6 insertions(+), 172 deletions(-) diff --git a/ami/base/permissions.py b/ami/base/permissions.py index 38f4c811a..723b1e58e 100644 --- a/ami/base/permissions.py +++ b/ami/base/permissions.py @@ -6,24 +6,7 @@ from guardian.shortcuts import get_perms from rest_framework import permissions -from ami.jobs.models import Job - -# from ami.main.api.views import DefaultViewSet -from ami.main.models import ( - BaseModel, - Deployment, - Device, - Project, - S3StorageSource, - Site, - SourceImage, - SourceImageCollection, - SourceImageUpload, -) -from ami.users.roles import ProjectManager - -# from rest_framework.viewsets import ModelViewSet - +from ami.main.models import BaseModel logger = logging.getLogger(__name__) @@ -94,149 +77,6 @@ def add_collection_level_permissions(user: User | None, response_data: dict, mod return response_data -class CRUDPermission(permissions.BasePermission): - """ - Generic CRUD permission class that dynamically checks user permissions on an object. - Permission names follow the convention: `create_`, `update_`, `delete_`. - """ - - model = None - - def has_permission(self, request, view): - """Handles general permission checks""" - - return True # Fallback to object level permissions - - def has_object_permission(self, request, view, obj): - """Handles object-level permission checks.""" - model_name = self.model._meta.model_name - project = obj.get_project() if hasattr(obj, "get_project") else None - # check for create action - if view.action == "create": - return request.user.is_superuser or request.user.has_perm(f"create_{model_name}", project) - - if view.action == "retrieve": - return True # Allow all users to view objects - - # Map ViewSet actions to permission names - action_perms = { - "retrieve": f"view_{model_name}", - "update": f"update_{model_name}", - "partial_update": f"update_{model_name}", - "destroy": f"delete_{model_name}", - } - - required_perm = action_perms.get(view.action) - if not required_perm: - return True - return request.user.has_perm(required_perm, project) - - -class ProjectCRUDPermission(CRUDPermission): - model = Project - - -class JobCRUDPermission(CRUDPermission): - model = Job - - -class DeploymentCRUDPermission(CRUDPermission): - model = Deployment - - -class SourceImageCollectionCRUDPermission(CRUDPermission): - model = SourceImageCollection - - -class SourceImageUploadCRUDPermission(CRUDPermission): - model = SourceImageUpload - - -class SourceImageCRUDPermission(CRUDPermission): - model = SourceImage - - -class CanStarSourceImage(permissions.BasePermission): - """Custom permission to check if the user can star a Source image.""" - - permission = Project.Permissions.STAR_SOURCE_IMAGE - - def has_object_permission(self, request, view, obj): - if view.action in ["unstar", "star"]: - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True - - -class S3StorageSourceCRUDPermission(CRUDPermission): - model = S3StorageSource - - -class SiteCRUDPermission(CRUDPermission): - model = Site - - -class DeviceCRUDPermission(CRUDPermission): - model = Device - - -# Identification permission checks -class CanUpdateIdentification(permissions.BasePermission): - """Custom permission to check if the user can update/create an identification.""" - - permission = Project.Permissions.UPDATE_IDENTIFICATION - - def has_object_permission(self, request, view, obj): - if view.action in ["create", "update", "partial_update"]: - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True - - -class CanDeleteIdentification(permissions.BasePermission): - """Custom permission to check if the user can delete an identification.""" - - permission = Project.Permissions.DELETE_IDENTIFICATION - - def has_object_permission(self, request, view, obj): - project = obj.get_project() if hasattr(obj, "get_project") else None - # Check if user is superuser or staff or project manager - if view.action == "destroy": - if request.user.is_superuser or ProjectManager.has_role(request.user, project): - return True - # Check if the user is the owner of the object - return obj.user == request.user - return True - - -# Job run permission check -class CanRunJob(permissions.BasePermission): - def has_object_permission(self, request, view, obj: Job): - return obj.has_permission(request.user, "run") - - -class CanRetryJob(permissions.BasePermission): - def has_object_permission(self, request, view, obj: Job): - return obj.has_permission(request.user, "retry") - - -class CanCancelJob(permissions.BasePermission): - def has_object_permission(self, request, view, obj: Job): - return obj.has_permission(request.user, "cancel") - - -class CanPopulateSourceImageCollection(permissions.BasePermission): - """Custom permission to check if the user can populate a collection.""" - - permission = Project.Permissions.POPULATE_COLLECTION - - def has_object_permission(self, request, view, obj): - if view.action == "populate": - project = obj.get_project() if hasattr(obj, "get_project") else None - return request.user.has_perm(self.permission, project) - return True - - class ObjectPermission(permissions.BasePermission): """ Generic permission class that delegates to the model's `check_permission(user, action)` method. diff --git a/ami/jobs/views.py b/ami/jobs/views.py index 6bc321f3e..5fffdb6fd 100644 --- a/ami/jobs/views.py +++ b/ami/jobs/views.py @@ -67,7 +67,7 @@ class JobViewSet(DefaultViewSet, ProjectMixin): "source_image_collection", "pipeline", ] - # permission_classes = [CanRunJob, CanRetryJob, CanCancelJob, JobCRUDPermission] + permission_classes = [ObjectPermission] def get_serializer_class(self): diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 9572f77c9..ef1f4bdc9 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -25,13 +25,7 @@ from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter from ami.base.pagination import LimitOffsetPaginationWithPermissions -from ami.base.permissions import ( - CanDeleteIdentification, - CanUpdateIdentification, - IsActiveStaffOrReadOnly, - ObjectPermission, - SourceImageUploadCRUDPermission, -) +from ami.base.permissions import IsActiveStaffOrReadOnly, ObjectPermission from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer from ami.base.views import ProjectMixin from ami.utils.requests import get_active_classification_threshold, project_id_doc_param @@ -747,7 +741,7 @@ class SourceImageUploadViewSet(DefaultViewSet, ProjectMixin): queryset = SourceImageUpload.objects.all() serializer_class = SourceImageUploadSerializer - permission_classes = [SourceImageUploadCRUDPermission] + permission_classes = [ObjectPermission] def get_queryset(self) -> QuerySet: # Only allow users to see their own uploads @@ -1547,8 +1541,8 @@ class IdentificationViewSet(DefaultViewSet): "updated_at", "user", ] - permission_classes = [CanUpdateIdentification, CanDeleteIdentification] - # permission_classes = [ObjectPermission] + + permission_classes = [ObjectPermission] def perform_create(self, serializer): """ From 19deb5f1a1c25cae4b8bf7500b99d1588b9874bf Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:23:02 -0400 Subject: [PATCH 23/38] Cleaned up BaseModel permission checks --- ami/base/models.py | 83 +++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/ami/base/models.py b/ami/base/models.py index bab1444c2..fd1ebc68f 100644 --- a/ami/base/models.py +++ b/ami/base/models.py @@ -31,7 +31,27 @@ def update_calculated_fields(self, *args, **kwargs): """Update calculated fields specific to each model.""" pass + def _get_object_perms(self, user): + """ + Get the object-level permissions for the user on this instance. + This method retrieves permissions like `update_modelname`, `create_modelname`, etc. + """ + project = self.get_project() + if not project: + return [] + + model_name = self._meta.model_name + all_perms = get_perms(user, project) + object_perms = [perm for perm in all_perms if perm.endswith(f"_{model_name}")] + return object_perms + def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: + """ + Check if the user has permission to perform the action + on this instance. + This method is used to determine if the user can perform + CRUD operations or custom actions on the model instance. + """ project = self.get_project() if hasattr(self, "get_project") else None if not project: return False @@ -54,7 +74,9 @@ def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> b return self.check_custom_permission(user, action) def check_custom_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: - """To be overridden in models for non-CRUD actions""" + """Check custom permissions for the user on this instance. + This is used for actions that are not standard CRUD operations. + """ assert self._meta.model_name is not None, "Model must have a model_name defined in Meta class." model_name = self._meta.model_name.lower() permission_codename = f"{action}_{model_name}" @@ -64,45 +86,38 @@ def check_custom_permission(self, user: AbstractUser | AnonymousUser, action: st def get_user_object_permissions(self, user) -> list[str]: """ - Returns a list of object-level permissions the user has on this instance, - based on their role in the associated project. + Returns a list of object-level permissions the user has on this instance. + This is used by frontend to determine what actions the user can perform. """ + # Return all permissions for superusers + if user.is_superuser: + allowed_custom_actions = self.get_custom_user_permissions(user) + return ["update", "delete"] + allowed_custom_actions - project = self.get_project() - if not project: - return [] + object_perms = self._get_object_perms(user) + # Check for update and delete permissions + allowed_actions = set() + for perm in object_perms: + action = perm.split("_", 1)[0] + if action in {"update", "delete"}: + allowed_actions.add(action) - if user.is_superuser: - custom_perms = self.get_custom_user_permissions(user) - return ["update", "delete"] + custom_perms - allowed_perms = set() - model_name = self._meta.model_name - perms = get_perms(user, project) - # check for update and delete permissions - actions = ["update", "delete"] - for action in actions: - if f"{action}_{model_name}" in perms: - allowed_perms.add(action) - custom_perms = self.get_custom_user_permissions(user) - allowed_perms.update(set(custom_perms)) - return list(allowed_perms) + allowed_custom_actions = self.get_custom_user_permissions(user) + allowed_actions.update(set(allowed_custom_actions)) + return list(allowed_actions) def get_custom_user_permissions(self, user: AbstractUser | AnonymousUser) -> list[str]: - project = self.get_project() - if not project: - return [] - + """ + Returns a list of custom permissions (not standard CRUD actions) that the user has on this instance. + """ + object_perms = self._get_object_perms(user) custom_perms = set() - model_name = self._meta.model_name - perms = get_perms(user, project) - for perm in perms: - # permissions are in the format "action_modelname" - if perm.endswith(f"_{model_name}"): - # process_single_image_sourceimage - action = perm.split("_", 1)[0] - # make sure to exclude standard CRUD actions - if action not in ["view", "create", "update", "delete"]: - custom_perms.add(action) + # Extract custom permissions that are not standard CRUD actions + for perm in object_perms: + action = perm.split("_", 1)[0] + # Make sure to exclude standard CRUD actions + if action not in ["view", "create", "update", "delete"]: + custom_perms.add(action) return list(custom_perms) class Meta: From 952e5ff14ab39af122e96eba4cf58182099703d8 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:24:42 -0400 Subject: [PATCH 24/38] Returned run_single_image_ml_job permission with the source image object --- ami/main/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ami/main/models.py b/ami/main/models.py index 2f6ad3bb9..4049de76a 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1592,7 +1592,7 @@ def get_custom_user_permissions(self, user) -> list[str]: if action not in ["view", "create", "update", "delete"]: custom_perms.add(action) if Project.Permissions.RUN_SINGLE_IMAGE_JOB in perms: - custom_perms.add("run_single_image") + custom_perms.add(Project.Permissions.RUN_SINGLE_IMAGE_JOB) return list(custom_perms) class Meta: From 4824df3ad8a37a71bc5f7bf0968511d444bbe1bc Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:25:55 -0400 Subject: [PATCH 25/38] Implemented custom permission checks for the identification model to handle the delete action. --- ami/main/models.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ami/main/models.py b/ami/main/models.py index 4049de76a..1d79051b6 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -12,6 +12,7 @@ import pydantic from django.apps import apps from django.conf import settings +from django.contrib.auth.models import AbstractUser, AnonymousUser from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.files.storage import default_storage @@ -1904,6 +1905,21 @@ def delete(self, *args, **kwargs): # Allow the update_occurrence_determination to determine the next best ID update_occurrence_determination(self.occurrence, current_determination=self.taxon) + def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: + """Custom permission check logic for Identification model.""" + import ami.users.roles as roles + + project = self.get_project() + if not project: + return False + + if action == "destroy": + # Allow if user is superuser, project manager, or owner of the identification + return user.is_superuser or roles.ProjectManager.has_role(user, project) or self.user == user + + # Fallback to base class permission checks + return super().check_permission(user, action) + @final class ClassificationResult(BaseModel): From 28c605de4a7ca8f090d6b892dc425e64ddafd849 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:37:56 -0400 Subject: [PATCH 26/38] Removed generic job run, cancel and retry permissions --- ami/main/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ami/main/models.py b/ami/main/models.py index 1d79051b6..13a650707 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -273,15 +273,12 @@ class Meta: # Job permissions ("create_job", "Can create a job"), ("update_job", "Can update a job"), - ("run_job", "Can run a job"), ("run_ml_job", "Can run/retry/cancel ML jobs"), ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), ("run_single_image_ml_job", "Can process a single capture"), ("delete_job", "Can delete a job"), - ("retry_job", "Can retry a job"), - ("cancel_job", "Can cancel a job"), # Deployment permissions ("create_deployment", "Can create a deployment"), ("delete_deployment", "Can delete a deployment"), From 1f8b0e21cc2cd85f51553d3660bcf0bf0e366533 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 11:39:33 -0400 Subject: [PATCH 27/38] Revoked ml job run permission --- ami/users/roles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ami/users/roles.py b/ami/users/roles.py index b8add17b9..7b4ea367f 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -76,7 +76,8 @@ class MLDataManager(Role): permissions = BasicMember.permissions | { Project.Permissions.CREATE_JOB, Project.Permissions.UPDATE_JOB, - # Project.Permissions.RUN_ML_JOB, + # RUN ML jobs is revoked for now + # Project.Permissions.RUN_JOB, Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, Project.Permissions.RUN_DATA_STORAGE_SYNC_JOB, Project.Permissions.RUN_DATA_EXPORT_JOB, @@ -92,7 +93,6 @@ class ProjectManager(Role): | Identifier.permissions | MLDataManager.permissions | { - Project.Permissions.CHANGE, Project.Permissions.CHANGE, Project.Permissions.DELETE, Project.Permissions.IMPORT_DATA, From 81fc2a9e406a6f15a54b3548303bcccca92a12c8 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 7 Jul 2025 18:37:42 -0400 Subject: [PATCH 28/38] Added a migration to delete deprecated job permissions --- .../0059_delete_deprecated_permissions.py | 31 +++++++++++++++++ ...0060_alter_sourceimagecollection_method.py | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 ami/main/migrations/0059_delete_deprecated_permissions.py create mode 100644 ami/main/migrations/0060_alter_sourceimagecollection_method.py diff --git a/ami/main/migrations/0059_delete_deprecated_permissions.py b/ami/main/migrations/0059_delete_deprecated_permissions.py new file mode 100644 index 000000000..a16aa1b73 --- /dev/null +++ b/ami/main/migrations/0059_delete_deprecated_permissions.py @@ -0,0 +1,31 @@ +from django.db import migrations + + +def delete_deprecated_permissions(apps, schema_editor): + Permission = apps.get_model("auth", "Permission") + ContentType = apps.get_model("contenttypes", "ContentType") + + deprecated_codenames = [ + "process_sourceimage", + "process_single_image_job", + "process_single_image_ml_job", + "run_job", + "retry_job", + "cancel_job", + ] + + permissions = Permission.objects.filter(codename__in=deprecated_codenames) + for perm in permissions: + print(f"Deleting permission: {perm.codename}") + perm.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("main", "0058_alter_project_options_squashed_0067_alter_project_options"), + ] + + operations = [ + migrations.RunPython(delete_deprecated_permissions, reverse_code=migrations.RunPython.noop), + ] diff --git a/ami/main/migrations/0060_alter_sourceimagecollection_method.py b/ami/main/migrations/0060_alter_sourceimagecollection_method.py new file mode 100644 index 000000000..3dd60349e --- /dev/null +++ b/ami/main/migrations/0060_alter_sourceimagecollection_method.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.10 on 2025-07-07 18:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0059_delete_deprecated_permissions"), + ] + + operations = [ + migrations.AlterField( + model_name="sourceimagecollection", + name="method", + field=models.CharField( + choices=[ + ("common_combined", "common_combined"), + ("random", "random"), + ("stratified_random", "stratified_random"), + ("interval", "interval"), + ("manual", "manual"), + ("starred", "starred"), + ("random_from_each_event", "random_from_each_event"), + ("last_and_random_from_each_event", "last_and_random_from_each_event"), + ("greatest_file_size_from_each_event", "greatest_file_size_from_each_event"), + ("detections_only", "detections_only"), + ("full", "full"), + ], + default="common_combined", + max_length=255, + ), + ), + ] From e215ac967e8531dff1182e1619ec9b03bf1afb58 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Tue, 8 Jul 2025 11:55:24 -0400 Subject: [PATCH 29/38] Added tests to make sure that job permissions are returned correctly to the front-end --- ami/main/tests.py | 86 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index a25c42b4a..f91ede6d8 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -1131,7 +1131,15 @@ def setUp(self) -> None: "sourceimageupload": {"create": True, "update": True, "delete": True}, "site": {"create": True, "update": True, "delete": True}, "device": {"create": True, "update": True, "delete": True}, - "job": {"create": True, "update": True, "delete": True, "run_single_image": True}, + "job": { + "create": True, + "update": True, + "delete": True, + "run_single_image": True, + "run": False, + "retry": False, + "cancel": False, + }, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1143,7 +1151,15 @@ def setUp(self) -> None: "sourceimage": {"create": False, "update": False, "delete": False}, "sourceimageupload": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False, "run_single_image": True}, + "job": { + "create": False, + "update": False, + "delete": False, + "run_single_image": True, + "run": False, + "retry": False, + "cancel": False, + }, "identification": {"create": False, "delete": False}, "capture": {"star": True, "unstar": True}, }, @@ -1155,7 +1171,15 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False, "run_single_image": True}, + "job": { + "create": False, + "update": False, + "delete": False, + "run_single_image": True, + "run": False, + "retry": False, + "cancel": False, + }, "identification": {"create": True, "update": True, "delete": True}, "capture": {"star": True, "unstar": True}, }, @@ -1167,7 +1191,15 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, - "job": {"create": False, "update": False, "delete": False, "run_single_image": False}, + "job": { + "create": False, + "update": False, + "delete": False, + "run_single_image": False, + "run": False, + "retry": False, + "cancel": False, + }, "identification": {"create": False, "delete": False}, "capture": {"star": False, "unstar": False}, }, @@ -1343,18 +1375,18 @@ def _test_role_permissions(self, role_class, user, permissions_map): logger.info(f"{entity} expected_status: {expected_status}, response_status:{response.status_code}") self.assertEqual(response.status_code, expected_status) - # # Step 3: Test Custom Actions - # if entity == "job" and entity_ids[entity]: - # for action in ["run", "retry", "cancel"]: - # logger.info(f"Testing {role_class} for job {action} custom permission") - # if action in actions: - # response = self.client.post(f"{endpoints[entity]}{entity_ids[entity]}/{action}/") - # expected_status = status.HTTP_200_OK if actions[action] else status.HTTP_403_FORBIDDEN - # self.assertEqual( - # response.status_code, - # expected_status, - # f"{role_class} {action} permission failed for {entity}", - # ) + # Step 3: Test Custom Actions + if entity == "job" and entity_ids[entity]: + for action in ["run", "retry", "cancel"]: + logger.info(f"Testing {role_class} for job {action} custom permission") + if action in actions: + response = self.client.post(f"{endpoints[entity]}{entity_ids[entity]}/{action}/") + expected_status = status.HTTP_200_OK if actions[action] else status.HTTP_403_FORBIDDEN + self.assertEqual( + response.status_code, + expected_status, + f"{role_class} {action} permission failed for {entity}", + ) if entity == "collection" and entity_ids[entity] and "populate" in actions: logger.info(f"Testing {role_class} for collection populate custom permission") @@ -1561,7 +1593,7 @@ def test_project_manager_permissions_(self): ) -class TestFineGrainedJobRunPermissionTests(APITestCase): +class TestFineGrainedJobRunPermission(APITestCase): def setUp(self): super().setUp() self.user = User.objects.create_user( @@ -1619,3 +1651,23 @@ def test_cannot_run_any_without_permission(self): job = self._create_job(job_type_key) response = self.client.post(f"/api/v2/jobs/{job.pk}/run/", format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, f"{job_type_key} should be denied") + + def test_user_permissions_reflected_in_job_detail(self): + job = self._create_job(job_type_key="ml") + + # By default, the user shouldn't have any job-related perms + response = self.client.get(f"/api/v2/jobs/{job.pk}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get("user_permissions"), [], "User should not have any job perms initially") + + # Assign run permission and check if it's reflected + assign_perm(Project.Permissions.RUN_ML_JOB, self.user, self.project) + response = self.client.get(f"/api/v2/jobs/{job.pk}/") + self.assertEqual(response.status_code, 200) + self.assertIn("run", response.data.get("user_permissions", [])) + + # Remove run permission and confirm it's removed + remove_perm(Project.Permissions.RUN_ML_JOB, self.user, self.project) + response = self.client.get(f"/api/v2/jobs/{job.pk}/") + self.assertEqual(response.status_code, 200) + self.assertNotIn("run", response.data.get("user_permissions", [])) From 6a16ac01caa65ddee741d839a50bdcb613493465 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Tue, 8 Jul 2025 11:56:05 -0400 Subject: [PATCH 30/38] Changed permission name for run single image --- ui/src/utils/user/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/utils/user/types.ts b/ui/src/utils/user/types.ts index 876d1603a..1a6d5833e 100644 --- a/ui/src/utils/user/types.ts +++ b/ui/src/utils/user/types.ts @@ -15,7 +15,7 @@ export enum UserPermission { Delete = 'delete', Populate = 'populate', // Custom collection permission Run = 'run', // Custom job permission - RunSingleImage = 'run_single_image', // Custom job permission + RunSingleImage = 'run_single_image_ml_job', // Custom job permission Star = 'star', Update = 'update', } From 4e045e5bfe7416f5c861535abfaac3a7c264362d Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Tue, 8 Jul 2025 16:47:23 -0400 Subject: [PATCH 31/38] Added tests for single image ml job --- ami/main/tests.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/ami/main/tests.py b/ami/main/tests.py index f91ede6d8..98f63e358 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -1671,3 +1671,77 @@ def test_user_permissions_reflected_in_job_detail(self): response = self.client.get(f"/api/v2/jobs/{job.pk}/") self.assertEqual(response.status_code, 200) self.assertNotIn("run", response.data.get("user_permissions", [])) + + +class TestRunSingleImageJobPermission(APITestCase): + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + email="regularuser@insectai.org", + password="password123", + ) + self.project = Project.objects.create(name="Single Image Project", description="Testing single image job") + self.pipeline = Pipeline.objects.create( + name="Test ML pipeline", + description="Test ML pipeline", + ) + self.pipeline.projects.add(self.project) + self.deployment = Deployment.objects.create(name="Test Deployment", project=self.project) + create_captures(deployment=self.deployment) + group_images_into_events(deployment=self.deployment) + self.capture = self.deployment.captures.first() + self.client.force_authenticate(self.user) + + def _grant_create_job__and_run_single_image_perm(self): + """Grants run_single_image_job permission on a specific capture to the user.""" + assign_perm(Project.Permissions.CREATE_JOB, self.user, self.project) + assign_perm(Project.Permissions.RUN_SINGLE_IMAGE_JOB, self.user, self.project) + + def _remove_run_single_image_perm(self): + remove_perm(Project.Permissions.RUN_SINGLE_IMAGE_JOB, self.user, self.project) + + def test_user_can_run_single_image_job_and_perm_is_reflected(self): + self._grant_create_job__and_run_single_image_perm() + + # Verify permission is reflected in capture detail response + capture_detail_url = f"/api/v2/captures/{self.capture.pk}/" + response = self.client.get(capture_detail_url) + self.assertEqual(response.status_code, 200) + self.assertIn( + "run_single_image_ml_job", + response.data.get("user_permissions", []), + "run_single_image permission not reflected", + ) + + # Try to run a job using source_image_single_id + run_url = "/api/v2/jobs/?start_now" + payload = { + "delay": 0, + "name": f"Capture #{self.capture.pk}", + "project_id": str(self.project.pk), + "pipeline_id": str(self.pipeline.pk), + "source_image_single_id": str(self.capture.pk), + } + response = self.client.post(run_url, payload, format="json") + self.assertEqual( + response.status_code, 201, f"User should be able to run single image job, got {response.status_code}" + ) + # Remove permission + self._remove_run_single_image_perm() + + # Permission should no longer appear in capture detail + response = self.client.get(capture_detail_url) + self.assertEqual(response.status_code, 200) + self.assertNotIn( + "run_single_image_ml_job", + response.data.get("user_permissions", []), + "run_single_image permission should be removed but still present", + ) + + # Should not be able to run job now + response = self.client.post(run_url, payload, format="json") + self.assertEqual( + response.status_code, + 403, + f"User should NOT be able to run single image job after permission removal, got {response.status_code}", + ) From 38b7fdf82e2e705cca87ec3833c032cf7380c16b Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Tue, 8 Jul 2025 17:00:16 -0400 Subject: [PATCH 32/38] Delete migration file --- ...0060_alter_sourceimagecollection_method.py | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 ami/main/migrations/0060_alter_sourceimagecollection_method.py diff --git a/ami/main/migrations/0060_alter_sourceimagecollection_method.py b/ami/main/migrations/0060_alter_sourceimagecollection_method.py deleted file mode 100644 index 3dd60349e..000000000 --- a/ami/main/migrations/0060_alter_sourceimagecollection_method.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-07 18:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0059_delete_deprecated_permissions"), - ] - - operations = [ - migrations.AlterField( - model_name="sourceimagecollection", - name="method", - field=models.CharField( - choices=[ - ("common_combined", "common_combined"), - ("random", "random"), - ("stratified_random", "stratified_random"), - ("interval", "interval"), - ("manual", "manual"), - ("starred", "starred"), - ("random_from_each_event", "random_from_each_event"), - ("last_and_random_from_each_event", "last_and_random_from_each_event"), - ("greatest_file_size_from_each_event", "greatest_file_size_from_each_event"), - ("detections_only", "detections_only"), - ("full", "full"), - ], - default="common_combined", - max_length=255, - ), - ), - ] From 2f87531fd54df925ce6fb4a2e8e5754ac58228f5 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Wed, 9 Jul 2025 00:24:52 -0400 Subject: [PATCH 33/38] Fixed conflicting migrations issue --- ami/main/migrations/0061_merge_20250709_0024.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 ami/main/migrations/0061_merge_20250709_0024.py diff --git a/ami/main/migrations/0061_merge_20250709_0024.py b/ami/main/migrations/0061_merge_20250709_0024.py new file mode 100644 index 000000000..d9430b69c --- /dev/null +++ b/ami/main/migrations/0061_merge_20250709_0024.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.10 on 2025-07-09 00:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0059_delete_deprecated_permissions"), + ("main", "0060_alter_sourceimagecollection_method"), + ] + + operations = [] From dd35803d59cc13dbbb699413131d511b35b11bdd Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 14 Aug 2025 17:47:32 -0700 Subject: [PATCH 34/38] chore: resolve migration conflicts --- .../0060_alter_sourceimagecollection_method.py | 2 +- ami/main/migrations/0061_merge_20250709_0024.py | 12 ------------ ...py => 0067_delete_deprecated_permissions.py} | 2 +- ...options.py => 0068_alter_project_options.py} | 17 ++--------------- 4 files changed, 4 insertions(+), 29 deletions(-) delete mode 100644 ami/main/migrations/0061_merge_20250709_0024.py rename ami/main/migrations/{0059_delete_deprecated_permissions.py => 0067_delete_deprecated_permissions.py} (90%) rename ami/main/migrations/{0058_alter_project_options_squashed_0067_alter_project_options.py => 0068_alter_project_options.py} (83%) diff --git a/ami/main/migrations/0060_alter_sourceimagecollection_method.py b/ami/main/migrations/0060_alter_sourceimagecollection_method.py index a32610c63..1f59c3875 100644 --- a/ami/main/migrations/0060_alter_sourceimagecollection_method.py +++ b/ami/main/migrations/0060_alter_sourceimagecollection_method.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("main", "0059_alter_project_options"), + ("main", "0058_alter_project_options"), ] operations = [ diff --git a/ami/main/migrations/0061_merge_20250709_0024.py b/ami/main/migrations/0061_merge_20250709_0024.py deleted file mode 100644 index d9430b69c..000000000 --- a/ami/main/migrations/0061_merge_20250709_0024.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 4.2.10 on 2025-07-09 00:24 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("main", "0059_delete_deprecated_permissions"), - ("main", "0060_alter_sourceimagecollection_method"), - ] - - operations = [] diff --git a/ami/main/migrations/0059_delete_deprecated_permissions.py b/ami/main/migrations/0067_delete_deprecated_permissions.py similarity index 90% rename from ami/main/migrations/0059_delete_deprecated_permissions.py rename to ami/main/migrations/0067_delete_deprecated_permissions.py index a16aa1b73..7cdcd26b4 100644 --- a/ami/main/migrations/0059_delete_deprecated_permissions.py +++ b/ami/main/migrations/0067_delete_deprecated_permissions.py @@ -23,7 +23,7 @@ def delete_deprecated_permissions(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("main", "0058_alter_project_options_squashed_0067_alter_project_options"), + ("main", "0066_alter_project_feature_flags_and_more"), ] operations = [ diff --git a/ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py b/ami/main/migrations/0068_alter_project_options.py similarity index 83% rename from ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py rename to ami/main/migrations/0068_alter_project_options.py index e425d83ab..1c69b8c79 100644 --- a/ami/main/migrations/0058_alter_project_options_squashed_0067_alter_project_options.py +++ b/ami/main/migrations/0068_alter_project_options.py @@ -1,24 +1,11 @@ -# Generated by Django 4.2.10 on 2025-07-07 10:41 +# Generated by Django 4.2.10 on 2025-08-14 21:11 from django.db import migrations class Migration(migrations.Migration): - replaces = [ - ("main", "0058_alter_project_options"), - ("main", "0059_alter_project_options"), - ("main", "0060_alter_project_options"), - ("main", "0061_alter_project_options"), - ("main", "0062_alter_project_options"), - ("main", "0063_alter_project_options"), - ("main", "0064_alter_project_options"), - ("main", "0065_alter_project_options"), - ("main", "0066_alter_project_options"), - ("main", "0067_alter_project_options"), - ] - dependencies = [ - ("main", "0057_merge_20250220_0022"), + ("main", "0067_delete_deprecated_permissions"), ] operations = [ From c956ffa0259dd09c6885e8a33b47de26e02dd616 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 18 Aug 2025 11:40:34 -0400 Subject: [PATCH 35/38] Use logger.info instead of print in migration for deleting deprecated permissions --- ami/main/migrations/0067_delete_deprecated_permissions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ami/main/migrations/0067_delete_deprecated_permissions.py b/ami/main/migrations/0067_delete_deprecated_permissions.py index 7cdcd26b4..a300ea666 100644 --- a/ami/main/migrations/0067_delete_deprecated_permissions.py +++ b/ami/main/migrations/0067_delete_deprecated_permissions.py @@ -1,4 +1,7 @@ from django.db import migrations +import logging + +logger = logging.getLogger(__name__) def delete_deprecated_permissions(apps, schema_editor): @@ -16,7 +19,7 @@ def delete_deprecated_permissions(apps, schema_editor): permissions = Permission.objects.filter(codename__in=deprecated_codenames) for perm in permissions: - print(f"Deleting permission: {perm.codename}") + logger.info(f"Deleting permission: {perm.codename}") perm.delete() From 1d18e316f0e10ad5f57ffbc088a2c161cdab7edd Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 18 Aug 2025 12:02:15 -0400 Subject: [PATCH 36/38] Merged migrations --- ami/main/migrations/0069_merge_20250818_1201.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 ami/main/migrations/0069_merge_20250818_1201.py diff --git a/ami/main/migrations/0069_merge_20250818_1201.py b/ami/main/migrations/0069_merge_20250818_1201.py new file mode 100644 index 000000000..4d3d7d9fc --- /dev/null +++ b/ami/main/migrations/0069_merge_20250818_1201.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.10 on 2025-08-18 12:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0067_alter_project_feature_flags"), + ("main", "0068_alter_project_options"), + ] + + operations = [] From 003e7e6542457aaaec0864f2d55c85e25de28d32 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 18 Aug 2025 15:41:51 -0400 Subject: [PATCH 37/38] Granted basic members create_job permission --- ami/users/roles.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ami/users/roles.py b/ami/users/roles.py index 7b4ea367f..a3c85c3c5 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -56,6 +56,7 @@ class BasicMember(Role): permissions = Role.permissions | { Project.Permissions.VIEW_PRIVATE_DATA, Project.Permissions.STAR_SOURCE_IMAGE, + Project.Permissions.CREATE_JOB, Project.Permissions.RUN_SINGLE_IMAGE_JOB, } @@ -77,7 +78,7 @@ class MLDataManager(Role): Project.Permissions.CREATE_JOB, Project.Permissions.UPDATE_JOB, # RUN ML jobs is revoked for now - # Project.Permissions.RUN_JOB, + # Project.Permissions.RUN_ML_JOB, Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, Project.Permissions.RUN_DATA_STORAGE_SYNC_JOB, Project.Permissions.RUN_DATA_EXPORT_JOB, From 1fd3f5d47dab41bfb1f0bd680732c77a244d0b5f Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Mon, 18 Aug 2025 15:49:44 -0400 Subject: [PATCH 38/38] Modified tests to allow basic members to create a job --- ami/main/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index b7c1f12b2..51329b232 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -1309,7 +1309,7 @@ def setUp(self) -> None: "sourceimageupload": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, "job": { - "create": False, + "create": True, "update": False, "delete": False, "run_single_image": True, @@ -1329,7 +1329,7 @@ def setUp(self) -> None: "site": {"create": False, "update": False, "delete": False}, "device": {"create": False, "update": False, "delete": False}, "job": { - "create": False, + "create": True, "update": False, "delete": False, "run_single_image": True,