Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
146 changes: 142 additions & 4 deletions ami/base/models.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,154 @@
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
from ami.users.models import User


def has_one_to_many_project_relation(model: type[models.Model]) -> bool:
"""
Returns True if the model has any ForeignKey or OneToOneField relationship to Project.
"""
from ami.main.models import Project

for field in model._meta.get_fields():
if isinstance(field, (models.ForeignKey, models.OneToOneField)) and field.related_model == Project:
return True

return False


def has_many_to_many_project_relation(model: type[models.Model]) -> bool:
"""
Returns True if the model has any forward or reverse ManyToMany relationship to Project.
"""
from ami.main.models import Project

# Forward M2M
for field in model._meta.get_fields():
if isinstance(field, models.ManyToManyField) and field.related_model == Project:
return True

# Reverse M2M
for rel in Project._meta.related_objects: # type: ignore
if rel.related_model == model and rel.many_to_many:
return True

return False


class BaseQuerySet(QuerySet):
def visible_for_user(self, user: User | AnonymousUser) -> QuerySet:
"""
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

# Superusers can see everything
if user.is_superuser:
return self

# Anonymous users can only see non-draft projects/objects
is_anonymous = isinstance(user, AnonymousUser)

model = self.model

# Handle Project model directly
if model == Project:
# Create a base filter condition for non-draft projects
filter_condition = Q(draft=False)

# If user is logged in, also include projects they own or are members of
if not is_anonymous:
filter_condition |= Q(owner=user) | Q(members=user)

return self.filter(filter_condition).distinct()

# For models related to Project
project_accessor = model.get_project_accessor()

# No project relationship: return unfiltered
if project_accessor is None:
return self

# Get project field path with trailing double underscore
project_field = f"{project_accessor}__"

# Create a base filter condition for objects related to non-draft projects
filter_condition = Q(**{f"{project_field}draft": False})

# If user is logged in, also include objects related to projects they own or are members of
if not is_anonymous:
filter_condition |= Q(**{f"{project_field}owner": user}) | Q(**{f"{project_field}members": user})

return self.filter(filter_condition).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) -> str | None:
"""
Determines the path to access the related Project from this model.

This method returns the appropriate accessor path based on the model's relationship to Project:

1. For direct ForeignKey or OneToOneField relationships to Project (occurrence.project)
- Returns "project" automatically (no need to define project_accessor)

2. For ManyToMany relationships to Project (pipeline.projects)
- Returns "projects" automatically (no need to define project_accessor)
- Note: Draft filtering will return objects with at least one non-draft project
- This is appropriate for global objects (pipelines, taxa, etc.) that can belong to multiple projects
- Such objects are never private data, unlike project-specific objects (occurrences, source_images)

3. For indirect relationships (accessed through other models) (detection.occurrence.project):
- Requires explicitly defining a 'project_accessor' class attribute
- Uses the Django double underscore convention ("__") to navigate through relationships
- Example: "deployment__project" (not "deployment.project")
where "deployment" is a field on this model and "project" is a field on Deployment

4. For the Project model itself:
- No project_accessor needed; will be handled by the isinstance check in get_project()

Returns:
str|None: The path to the related project, or None for no relationship or the Project model itself.
"""

if has_one_to_many_project_relation(cls):
return "project" # One-to-many or one-to-one relation

if has_many_to_many_project_relation(cls):
return "projects" # Many-to-many relation

return getattr(cls, "project_accessor", None)

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."""
from ami.main.models import Project

if isinstance(self, Project):
return self

accessor = self.get_project_accessor()
if accessor == "projects" or accessor is None:
return None

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 +187,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
1 change: 1 addition & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ class Meta:
"created_at",
"updated_at",
"image",
"draft",
]


Expand Down
52 changes: 37 additions & 15 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from rest_framework.views import APIView

from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
from ami.base.models import BaseQuerySet
from ami.base.pagination import LimitOffsetPaginationWithPermissions
from ami.base.permissions import IsActiveStaffOrReadOnly, ObjectPermission
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
Expand Down Expand Up @@ -120,6 +121,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 isinstance(qs, BaseQuerySet):
return qs.visible_for_user(self.request.user) # type: ignore

return qs


class DefaultReadOnlyViewSet(DefaultViewSetMixin, viewsets.ReadOnlyModelViewSet):
pass
Expand Down Expand Up @@ -1536,28 +1546,40 @@ 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_for_user(user).count(), # type: ignore
"deployments_count": Deployment.objects.visible_for_user(user) # type: ignore
.filter(project=project)
.count(),
"events_count": Event.objects.visible_for_user(user) # type: ignore
.filter(deployment__project=project, deployment__isnull=False)
.count(),
"captures_count": SourceImage.objects.visible_for_user(user) # type: ignore
.filter(deployment__project=project)
.count(),
"occurrences_count": Occurrence.objects.valid()
.visible_for_user(user)
.filter(project=project)
.count(), # type: ignore
"taxa_count": Occurrence.objects.visible_for_user(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_for_user(user).count(), # type: ignore
"deployments_count": Deployment.objects.visible_for_user(user).count(), # type: ignore
"events_count": Event.objects.visible_for_user(user) # type: ignore
.filter(deployment__isnull=False)
.count(),
"captures_count": SourceImage.objects.visible_for_user(user).count(), # type: ignore
"occurrences_count": Occurrence.objects.valid().visible_for_user(user).count(), # type: ignore
"taxa_count": Occurrence.objects.visible_for_user(user).unique_taxa().count(), # type: ignore
"last_updated": timezone.now(),
}

Expand Down
17 changes: 17 additions & 0 deletions ami/main/migrations/0072_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-09-12 01:00

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("main", "0071_alter_project_options"),
]

operations = [
migrations.AddField(
model_name="project",
name="draft",
field=models.BooleanField(default=False, help_text="Indicates whether this project is in draft mode"),
),
]
Loading