Skip to content
Merged
Show file tree
Hide file tree
Changes from 61 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
9c8a432
Moved permission checks to the BaseModel class
mohamedelabbas1996 Jul 2, 2025
4fdae24
Refactor Job model permission checks to include job type for finer-gr…
mohamedelabbas1996 Jul 2, 2025
c43ef9c
Added fine-grained permissions for each job type
mohamedelabbas1996 Jul 2, 2025
4407af1
Modified roles to use the fine-grained job permissions
mohamedelabbas1996 Jul 2, 2025
1ec1595
Updated views to use the new permission checks
mohamedelabbas1996 Jul 2, 2025
c4de3de
Removed old job permissions from tests
mohamedelabbas1996 Jul 2, 2025
43d7bbf
Added migration file
mohamedelabbas1996 Jul 2, 2025
35a6ea8
Merge branch 'main' into feat/restrict-ml-processing-jobs
mohamedelabbas1996 Jul 2, 2025
b4ef1bc
Added permission for processing single source image
mohamedelabbas1996 Jul 3, 2025
a83e919
Merge branch 'feat/restrict-ml-processing-jobs' of https://github.com…
mohamedelabbas1996 Jul 3, 2025
095b899
Changed PermissionError to PermissionDenied
mohamedelabbas1996 Jul 3, 2025
c694706
Added migration file
mohamedelabbas1996 Jul 3, 2025
5515a87
Merge branch 'main' into feat/restrict-ml-processing-jobs
mohamedelabbas1996 Jul 3, 2025
3fc6906
Merge branch 'main' into feat/restrict-ml-processing-jobs
mohamedelabbas1996 Jul 3, 2025
5123aef
tests: Added tests for fine-grained job run permission
mohamedelabbas1996 Jul 3, 2025
805f87b
Move process single source image permission from source image to job …
mohamedelabbas1996 Jul 3, 2025
e995d9d
Added migration files
mohamedelabbas1996 Jul 3, 2025
cdbae94
Remove process_sourceimage permission from job details response
mohamedelabbas1996 Jul 3, 2025
22882a6
Added type hints
mohamedelabbas1996 Jul 4, 2025
a7eab7a
Modified process single image permission name
mohamedelabbas1996 Jul 4, 2025
4ee7e53
Added migration file
mohamedelabbas1996 Jul 4, 2025
7adf27d
Changed tests to check for run single image permission in the job det…
mohamedelabbas1996 Jul 4, 2025
9642ab7
chore: update FE permission handling for processing single captures
annavik Jul 4, 2025
b4e16ad
chore: update FE permission handling to use single run permission
annavik Jul 4, 2025
f385af3
Squashed migrations
mohamedelabbas1996 Jul 7, 2025
a9b39d7
Merge branch 'feat/restrict-ml-processing-jobs' of https://github.com…
mohamedelabbas1996 Jul 7, 2025
d166865
Merge branch 'main' into feat/restrict-ml-processing-jobs
mohamedelabbas1996 Jul 7, 2025
a15e9cf
Cleaned up permission check classes defined for each model and used a…
mohamedelabbas1996 Jul 7, 2025
19deb5f
Cleaned up BaseModel permission checks
mohamedelabbas1996 Jul 7, 2025
952e5ff
Returned run_single_image_ml_job permission with the source image object
mohamedelabbas1996 Jul 7, 2025
4824df3
Implemented custom permission checks for the identification model to …
mohamedelabbas1996 Jul 7, 2025
28c605d
Removed generic job run, cancel and retry permissions
mohamedelabbas1996 Jul 7, 2025
1f8b0e2
Revoked ml job run permission
mohamedelabbas1996 Jul 7, 2025
737da22
Merge branch 'feat/restrict-ml-processing-jobs' of https://github.com…
mohamedelabbas1996 Jul 7, 2025
81fc2a9
Added a migration to delete deprecated job permissions
mohamedelabbas1996 Jul 7, 2025
e215ac9
Added tests to make sure that job permissions are returned correctly …
mohamedelabbas1996 Jul 8, 2025
6a16ac0
Changed permission name for run single image
mohamedelabbas1996 Jul 8, 2025
4e045e5
Added tests for single image ml job
mohamedelabbas1996 Jul 8, 2025
38b7fdf
Delete migration file
mohamedelabbas1996 Jul 8, 2025
d7028e7
Merge branch 'main' into feat/restrict-ml-processing-jobs
mohamedelabbas1996 Jul 9, 2025
2f87531
Fixed conflicting migrations issue
mohamedelabbas1996 Jul 9, 2025
591f7a4
Add draft field to Project model
mohamedelabbas1996 Aug 11, 2025
46d76fe
Cleaned project permissions names
mohamedelabbas1996 Aug 11, 2025
b128148
Added view permission check for draft projects
mohamedelabbas1996 Aug 11, 2025
4014a95
Added draft field to the project admin page
mohamedelabbas1996 Aug 11, 2025
987e30b
Added tests for draft project view permission
mohamedelabbas1996 Aug 11, 2025
eb2d5f9
Added BaseQuerySet with draft project visibility filter
mohamedelabbas1996 Aug 15, 2025
b57c373
Use BaseQuerySet and define project_accessor on models
mohamedelabbas1996 Aug 15, 2025
5366727
Add helper to check user access to draft projects
mohamedelabbas1996 Aug 15, 2025
c1e4a94
Filter queryset in default viewset based on draft project visibility
mohamedelabbas1996 Aug 15, 2025
0a72691
Added tests for draft project and related object listing
mohamedelabbas1996 Aug 15, 2025
955b345
Optimize visible_draft_projects_only in BaseQuerySet to improve filte…
mohamedelabbas1996 Aug 19, 2025
82ac8ec
Add project_accessor string to models to specify their relationship t…
mohamedelabbas1996 Aug 20, 2025
231a534
Clean base queryset filter method logic
mohamedelabbas1996 Aug 22, 2025
d7f3c03
Modify summary endpoint to stats only from visible draft projects
mohamedelabbas1996 Aug 22, 2025
7f60b4d
Merge branch 'main' into feat/restrict-draft-projects-permissions
mohamedelabbas1996 Aug 22, 2025
d5e3643
Fixed formatting errors
mohamedelabbas1996 Aug 22, 2025
58375df
Fixed draft project filtering for PipelineQuerySet
mohamedelabbas1996 Aug 22, 2025
c5b1fd4
Merged migrations
mohamedelabbas1996 Aug 22, 2025
2331e83
Added tests for draft project related deployment objects
mohamedelabbas1996 Aug 22, 2025
f292829
Fixed formatting errors
mohamedelabbas1996 Aug 22, 2025
2736ca8
chore: rebase migrations on top of what's in main
mihow Aug 22, 2025
0fdb221
chore: add missing migration
mihow Aug 23, 2025
f73348b
Use project accessor only for models with indirect one to many relati…
mohamedelabbas1996 Aug 25, 2025
94797d4
Change BaseQuerySet filter method name to visible_for_user
mohamedelabbas1996 Aug 25, 2025
b03b4a8
Modify the ProcessingServiceManager to inherit the BaseQuerySet
mohamedelabbas1996 Aug 25, 2025
35d94a2
Add tests for visible_for_user filter method
mohamedelabbas1996 Aug 25, 2025
788e87b
feat: add test for comparing public & private summary counts
mihow Sep 12, 2025
8fc5927
feat: check exact counts in summary stats tests
mihow Sep 12, 2025
670284a
Merge branch 'main' of github.com:RolnickLab/antenna into feat/restri…
mihow Sep 12, 2025
35e28d2
fix: rebase migrations
mihow Sep 12, 2025
a2bd161
feat: check if self is a Project rather than using empty string
mihow Sep 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions ami/base/models.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
from django.contrib.auth.models import AbstractUser, AnonymousUser
from django.db import models
from django.db.models import Q, QuerySet
from guardian.shortcuts import get_perms

import ami.tasks


class BaseQuerySet(QuerySet):
def visible_draft_projects_only(self, user):
"""
Filter queryset to include only objects whose related draft projects
are visible to the given user. Only superusers, project owners,
or members are allowed to view draft projects and their related objects.
"""
from ami.main.models import Project

if user.is_superuser:
return self

# Determine whether the model is Project itself
is_project_model = self.model == Project

# Use model-defined project accessor if available
project_accessor = getattr(self.model, "project_accessor", "project")
# For models that have many2many relationship with the project model
# or no relationship just return the qs without filtering
if not project_accessor and not is_project_model:
return self
project_field = "" if is_project_model else f"{project_accessor}__"

# Build Q filters
non_draft_filter = Q(**{f"{project_field}draft": False})
# Show only non-draft projects for anonymous users
if isinstance(user, AnonymousUser):
return self.filter(non_draft_filter).distinct()

owner_filter = Q(**{f"{project_field}owner": user})
member_filter = Q(**{f"{project_field}members": user})

return self.filter(non_draft_filter | owner_filter | member_filter).distinct()


class BaseModel(models.Model):
""" """

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = BaseQuerySet.as_manager()

@classmethod
def get_project_accessor(cls):
return getattr(cls, "project_accessor", "project")

def get_project(self):
"""Get the project associated with the model."""
return self.project if hasattr(self, "project") else None
"""Dynamically get the related project using the project_accessor."""
accessor = self.get_project_accessor()
if not accessor:
return self
project = self
for part in accessor.split("__"):
project = getattr(project, part, None)
if project is None:
break
return project

def __str__(self) -> str:
"""All django models should have this method."""
Expand Down Expand Up @@ -52,13 +101,16 @@ def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> b
This method is used to determine if the user can perform
CRUD operations or custom actions on the model instance.
"""
from ami.users.roles import BasicMember

project = self.get_project() if hasattr(self, "get_project") else None
if not project:
return False
if action == "retrieve":
# Allow view
if project.draft:
# Allow view permission for members and owners of draft projects
return BasicMember.has_role(user, project) or user == project.owner or user.is_superuser
return True

model = self._meta.model_name
crud_map = {
"create": f"create_{model}",
Expand Down
1 change: 1 addition & 0 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def save_related(self, request, form, formsets, change):
"description",
"priority",
"active",
"draft",
"feature_flags",
)
},
Expand Down
55 changes: 40 additions & 15 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ def create(self, request, *args, **kwargs):
self.perform_create(serializer)
return Response(serializer.data, status=status.HTTP_201_CREATED)

def get_queryset(self):
qs: QuerySet = super().get_queryset()
assert self.queryset is not None

if hasattr(self.queryset.model, "get_project_accessor"):
return qs.visible_draft_projects_only(self.request.user) # type: ignore

return qs


class DefaultReadOnlyViewSet(DefaultViewSetMixin, viewsets.ReadOnlyModelViewSet):
pass
Expand Down Expand Up @@ -1536,28 +1545,44 @@ class SummaryView(GenericAPIView, ProjectMixin):
@extend_schema(parameters=[project_id_doc_param])
def get(self, request):
"""
Return counts of all models.
Return counts of all models, applying visibility filters for draft projects.
"""
user = request.user
project = self.get_active_project()
if project:
data = {
"projects_count": Project.objects.count(), # @TODO filter by current user, here and everywhere!
"deployments_count": Deployment.objects.filter(project=project).count(),
"events_count": Event.objects.filter(deployment__project=project, deployment__isnull=False).count(),
"captures_count": SourceImage.objects.filter(deployment__project=project).count(),
# "detections_count": Detection.objects.filter(occurrence__project=project).count(),
"occurrences_count": Occurrence.objects.valid().filter(project=project).count(), # type: ignore
"taxa_count": Occurrence.objects.all().unique_taxa(project=project).count(), # type: ignore
"projects_count": Project.objects.visible_draft_projects_only(user).count(), # type: ignore
"deployments_count": Deployment.objects.visible_draft_projects_only(user) # type: ignore
.filter(project=project)
.count(),
"events_count": Event.objects.visible_draft_projects_only(user) # type: ignore
.filter(deployment__project=project, deployment__isnull=False)
.count(),
"captures_count": SourceImage.objects.visible_draft_projects_only(user) # type: ignore
.filter(deployment__project=project)
.count(),
"occurrences_count": Occurrence.objects.valid()
.visible_draft_projects_only(user)
.filter(project=project)
.count(), # type: ignore
"taxa_count": Occurrence.objects.visible_draft_projects_only(user)
.unique_taxa(project=project)
.count(), # type: ignore
}
else:
data = {
"projects_count": Project.objects.count(),
"deployments_count": Deployment.objects.count(),
"events_count": Event.objects.filter(deployment__isnull=False).count(),
"captures_count": SourceImage.objects.count(),
# "detections_count": Detection.objects.count(),
"occurrences_count": Occurrence.objects.valid().count(), # type: ignore
"taxa_count": Occurrence.objects.all().unique_taxa().count(), # type: ignore
"projects_count": Project.objects.visible_draft_projects_only(user).count(), # type: ignore
"deployments_count": Deployment.objects.visible_draft_projects_only(user).count(), # type: ignore
"events_count": Event.objects.visible_draft_projects_only(user) # type: ignore
.filter(deployment__isnull=False)
.count(),
"captures_count": SourceImage.objects.visible_draft_projects_only(user).count(), # type: ignore
"occurrences_count": Occurrence.objects.valid()
.visible_draft_projects_only(user)
.count(), # type: ignore
"taxa_count": Occurrence.objects.visible_draft_projects_only(user)
.unique_taxa()
.count(), # type: ignore
"last_updated": timezone.now(),
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
# Generated by Django 4.2.10 on 2025-02-22 19:23
# Generated by Django 4.2.10 on 2025-07-07 10:41

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"),
]
Expand All @@ -19,18 +32,26 @@ 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"),
("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"),
Expand Down
31 changes: 31 additions & 0 deletions ami/main/migrations/0059_delete_deprecated_permissions.py
Original file line number Diff line number Diff line change
@@ -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),
]
12 changes: 12 additions & 0 deletions ami/main/migrations/0061_merge_20250709_0024.py
Original file line number Diff line number Diff line change
@@ -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 = []
17 changes: 17 additions & 0 deletions ami/main/migrations/0062_project_draft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2025-08-11 07:46

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("main", "0061_merge_20250709_0024"),
]

operations = [
migrations.AddField(
model_name="project",
name="draft",
field=models.BooleanField(default=False, help_text="Indicates whether this project is in draft mode"),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 4.2.10 on 2025-08-22 16:04

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("main", "0062_project_draft"),
("main", "0069_merge_20250818_1201"),
]

operations = []
Loading