Skip to content

Commit c834d98

Browse files
mohamedelabbas1996annavikmihow
authored
Support for draft projects & private draft permissions (#917)
* Moved permission checks to the BaseModel class * Refactor Job model permission checks to include job type for finer-grained control * Added fine-grained permissions for each job type * Modified roles to use the fine-grained job permissions * Updated views to use the new permission checks * Removed old job permissions from tests * Added migration file * Added permission for processing single source image * Changed PermissionError to PermissionDenied * Added migration file * tests: Added tests for fine-grained job run permission * Move process single source image permission from source image to job model * Added migration files * Remove process_sourceimage permission from job details response * Added type hints * Modified process single image permission name * Added migration file * Changed tests to check for run single image permission in the job details response * chore: update FE permission handling for processing single captures * chore: update FE permission handling to use single run permission * Squashed migrations * Cleaned up permission check classes defined for each model and used a generic permission check instead * Cleaned up BaseModel permission checks * Returned run_single_image_ml_job permission with the source image object * Implemented custom permission checks for the identification model to handle the delete action. * Removed generic job run, cancel and retry permissions * Revoked ml job run permission * Added a migration to delete deprecated job permissions * Added tests to make sure that job permissions are returned correctly to the front-end * Changed permission name for run single image * Added tests for single image ml job * Delete migration file * Fixed conflicting migrations issue * Add draft field to Project model * Cleaned project permissions names * Added view permission check for draft projects * Added draft field to the project admin page * Added tests for draft project view permission * Added BaseQuerySet with draft project visibility filter * Use BaseQuerySet and define project_accessor on models * Add helper to check user access to draft projects * Filter queryset in default viewset based on draft project visibility * Added tests for draft project and related object listing * Optimize visible_draft_projects_only in BaseQuerySet to improve filtering performance * Add project_accessor string to models to specify their relationship to Project for use in BaseQuerySet filter method * Clean base queryset filter method logic * Modify summary endpoint to stats only from visible draft projects * Fixed formatting errors * Fixed draft project filtering for PipelineQuerySet * Merged migrations * Added tests for draft project related deployment objects * Fixed formatting errors * chore: rebase migrations on top of what's in main * chore: add missing migration * Use project accessor only for models with indirect one to many relationship with the project model * Change BaseQuerySet filter method name to visible_for_user * Modify the ProcessingServiceManager to inherit the BaseQuerySet * Add tests for visible_for_user filter method * feat: add test for comparing public & private summary counts * feat: check exact counts in summary stats tests * fix: rebase migrations * feat: check if self is a Project rather than using empty string --------- Co-authored-by: Anna Viklund <annamariaviklund@gmail.com> Co-authored-by: Michael Bunsen <notbot@gmail.com>
1 parent 713bb28 commit c834d98

File tree

12 files changed

+576
-78
lines changed

12 files changed

+576
-78
lines changed

ami/base/models.py

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,154 @@
11
from django.contrib.auth.models import AbstractUser, AnonymousUser
22
from django.db import models
3+
from django.db.models import Q, QuerySet
34
from guardian.shortcuts import get_perms
45

56
import ami.tasks
7+
from ami.users.models import User
8+
9+
10+
def has_one_to_many_project_relation(model: type[models.Model]) -> bool:
11+
"""
12+
Returns True if the model has any ForeignKey or OneToOneField relationship to Project.
13+
"""
14+
from ami.main.models import Project
15+
16+
for field in model._meta.get_fields():
17+
if isinstance(field, (models.ForeignKey, models.OneToOneField)) and field.related_model == Project:
18+
return True
19+
20+
return False
21+
22+
23+
def has_many_to_many_project_relation(model: type[models.Model]) -> bool:
24+
"""
25+
Returns True if the model has any forward or reverse ManyToMany relationship to Project.
26+
"""
27+
from ami.main.models import Project
28+
29+
# Forward M2M
30+
for field in model._meta.get_fields():
31+
if isinstance(field, models.ManyToManyField) and field.related_model == Project:
32+
return True
33+
34+
# Reverse M2M
35+
for rel in Project._meta.related_objects: # type: ignore
36+
if rel.related_model == model and rel.many_to_many:
37+
return True
38+
39+
return False
40+
41+
42+
class BaseQuerySet(QuerySet):
43+
def visible_for_user(self, user: User | AnonymousUser) -> QuerySet:
44+
"""
45+
Filter queryset to include only objects whose related draft projects
46+
are visible to the given user. Only superusers, project owners,
47+
or members are allowed to view draft projects and their related objects.
48+
"""
49+
from ami.main.models import Project
50+
51+
# Superusers can see everything
52+
if user.is_superuser:
53+
return self
54+
55+
# Anonymous users can only see non-draft projects/objects
56+
is_anonymous = isinstance(user, AnonymousUser)
57+
58+
model = self.model
59+
60+
# Handle Project model directly
61+
if model == Project:
62+
# Create a base filter condition for non-draft projects
63+
filter_condition = Q(draft=False)
64+
65+
# If user is logged in, also include projects they own or are members of
66+
if not is_anonymous:
67+
filter_condition |= Q(owner=user) | Q(members=user)
68+
69+
return self.filter(filter_condition).distinct()
70+
71+
# For models related to Project
72+
project_accessor = model.get_project_accessor()
73+
74+
# No project relationship: return unfiltered
75+
if project_accessor is None:
76+
return self
77+
78+
# Get project field path with trailing double underscore
79+
project_field = f"{project_accessor}__"
80+
81+
# Create a base filter condition for objects related to non-draft projects
82+
filter_condition = Q(**{f"{project_field}draft": False})
83+
84+
# If user is logged in, also include objects related to projects they own or are members of
85+
if not is_anonymous:
86+
filter_condition |= Q(**{f"{project_field}owner": user}) | Q(**{f"{project_field}members": user})
87+
88+
return self.filter(filter_condition).distinct()
689

790

891
class BaseModel(models.Model):
992
""" """
1093

1194
created_at = models.DateTimeField(auto_now_add=True)
1295
updated_at = models.DateTimeField(auto_now=True)
96+
objects = BaseQuerySet.as_manager()
97+
98+
@classmethod
99+
def get_project_accessor(cls) -> str | None:
100+
"""
101+
Determines the path to access the related Project from this model.
102+
103+
This method returns the appropriate accessor path based on the model's relationship to Project:
104+
105+
1. For direct ForeignKey or OneToOneField relationships to Project (occurrence.project)
106+
- Returns "project" automatically (no need to define project_accessor)
107+
108+
2. For ManyToMany relationships to Project (pipeline.projects)
109+
- Returns "projects" automatically (no need to define project_accessor)
110+
- Note: Draft filtering will return objects with at least one non-draft project
111+
- This is appropriate for global objects (pipelines, taxa, etc.) that can belong to multiple projects
112+
- Such objects are never private data, unlike project-specific objects (occurrences, source_images)
113+
114+
3. For indirect relationships (accessed through other models) (detection.occurrence.project):
115+
- Requires explicitly defining a 'project_accessor' class attribute
116+
- Uses the Django double underscore convention ("__") to navigate through relationships
117+
- Example: "deployment__project" (not "deployment.project")
118+
where "deployment" is a field on this model and "project" is a field on Deployment
119+
120+
4. For the Project model itself:
121+
- No project_accessor needed; will be handled by the isinstance check in get_project()
122+
123+
Returns:
124+
str|None: The path to the related project, or None for no relationship or the Project model itself.
125+
"""
126+
127+
if has_one_to_many_project_relation(cls):
128+
return "project" # One-to-many or one-to-one relation
129+
130+
if has_many_to_many_project_relation(cls):
131+
return "projects" # Many-to-many relation
132+
133+
return getattr(cls, "project_accessor", None)
13134

14135
def get_project(self):
15-
"""Get the project associated with the model."""
16-
return self.project if hasattr(self, "project") else None
136+
"""Dynamically get the related project using the project_accessor."""
137+
from ami.main.models import Project
138+
139+
if isinstance(self, Project):
140+
return self
141+
142+
accessor = self.get_project_accessor()
143+
if accessor == "projects" or accessor is None:
144+
return None
145+
146+
project = self
147+
for part in accessor.split("__"):
148+
project = getattr(project, part, None)
149+
if project is None:
150+
break
151+
return project
17152

18153
def __str__(self) -> str:
19154
"""All django models should have this method."""
@@ -52,13 +187,16 @@ def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> b
52187
This method is used to determine if the user can perform
53188
CRUD operations or custom actions on the model instance.
54189
"""
190+
from ami.users.roles import BasicMember
191+
55192
project = self.get_project() if hasattr(self, "get_project") else None
56193
if not project:
57194
return False
58195
if action == "retrieve":
59-
# Allow view
196+
if project.draft:
197+
# Allow view permission for members and owners of draft projects
198+
return BasicMember.has_role(user, project) or user == project.owner or user.is_superuser
60199
return True
61-
62200
model = self._meta.model_name
63201
crud_map = {
64202
"create": f"create_{model}",

ami/main/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def save_related(self, request, form, formsets, change):
8787
"description",
8888
"priority",
8989
"active",
90+
"draft",
9091
"feature_flags",
9192
)
9293
},

ami/main/api/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ class Meta:
280280
"created_at",
281281
"updated_at",
282282
"image",
283+
"draft",
283284
]
284285

285286

ami/main/api/views.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from rest_framework.views import APIView
2525

2626
from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
27+
from ami.base.models import BaseQuerySet
2728
from ami.base.pagination import LimitOffsetPaginationWithPermissions
2829
from ami.base.permissions import IsActiveStaffOrReadOnly, ObjectPermission
2930
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
@@ -120,6 +121,15 @@ def create(self, request, *args, **kwargs):
120121
self.perform_create(serializer)
121122
return Response(serializer.data, status=status.HTTP_201_CREATED)
122123

124+
def get_queryset(self):
125+
qs: QuerySet = super().get_queryset()
126+
assert self.queryset is not None
127+
128+
if isinstance(qs, BaseQuerySet):
129+
return qs.visible_for_user(self.request.user) # type: ignore
130+
131+
return qs
132+
123133

124134
class DefaultReadOnlyViewSet(DefaultViewSetMixin, viewsets.ReadOnlyModelViewSet):
125135
pass
@@ -1536,28 +1546,40 @@ class SummaryView(GenericAPIView, ProjectMixin):
15361546
@extend_schema(parameters=[project_id_doc_param])
15371547
def get(self, request):
15381548
"""
1539-
Return counts of all models.
1549+
Return counts of all models, applying visibility filters for draft projects.
15401550
"""
1551+
user = request.user
15411552
project = self.get_active_project()
15421553
if project:
15431554
data = {
1544-
"projects_count": Project.objects.count(), # @TODO filter by current user, here and everywhere!
1545-
"deployments_count": Deployment.objects.filter(project=project).count(),
1546-
"events_count": Event.objects.filter(deployment__project=project, deployment__isnull=False).count(),
1547-
"captures_count": SourceImage.objects.filter(deployment__project=project).count(),
1548-
# "detections_count": Detection.objects.filter(occurrence__project=project).count(),
1549-
"occurrences_count": Occurrence.objects.valid().filter(project=project).count(), # type: ignore
1550-
"taxa_count": Occurrence.objects.all().unique_taxa(project=project).count(), # type: ignore
1555+
"projects_count": Project.objects.visible_for_user(user).count(), # type: ignore
1556+
"deployments_count": Deployment.objects.visible_for_user(user) # type: ignore
1557+
.filter(project=project)
1558+
.count(),
1559+
"events_count": Event.objects.visible_for_user(user) # type: ignore
1560+
.filter(deployment__project=project, deployment__isnull=False)
1561+
.count(),
1562+
"captures_count": SourceImage.objects.visible_for_user(user) # type: ignore
1563+
.filter(deployment__project=project)
1564+
.count(),
1565+
"occurrences_count": Occurrence.objects.valid()
1566+
.visible_for_user(user)
1567+
.filter(project=project)
1568+
.count(), # type: ignore
1569+
"taxa_count": Occurrence.objects.visible_for_user(user)
1570+
.unique_taxa(project=project)
1571+
.count(), # type: ignore
15511572
}
15521573
else:
15531574
data = {
1554-
"projects_count": Project.objects.count(),
1555-
"deployments_count": Deployment.objects.count(),
1556-
"events_count": Event.objects.filter(deployment__isnull=False).count(),
1557-
"captures_count": SourceImage.objects.count(),
1558-
# "detections_count": Detection.objects.count(),
1559-
"occurrences_count": Occurrence.objects.valid().count(), # type: ignore
1560-
"taxa_count": Occurrence.objects.all().unique_taxa().count(), # type: ignore
1575+
"projects_count": Project.objects.visible_for_user(user).count(), # type: ignore
1576+
"deployments_count": Deployment.objects.visible_for_user(user).count(), # type: ignore
1577+
"events_count": Event.objects.visible_for_user(user) # type: ignore
1578+
.filter(deployment__isnull=False)
1579+
.count(),
1580+
"captures_count": SourceImage.objects.visible_for_user(user).count(), # type: ignore
1581+
"occurrences_count": Occurrence.objects.valid().visible_for_user(user).count(), # type: ignore
1582+
"taxa_count": Occurrence.objects.visible_for_user(user).unique_taxa().count(), # type: ignore
15611583
"last_updated": timezone.now(),
15621584
}
15631585

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.10 on 2025-09-12 01:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("main", "0071_alter_project_options"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="project",
14+
name="draft",
15+
field=models.BooleanField(default=False, help_text="Indicates whether this project is in draft mode"),
16+
),
17+
]

0 commit comments

Comments
 (0)