|
1 | 1 | from django.contrib.auth.models import AbstractUser, AnonymousUser
|
2 | 2 | from django.db import models
|
| 3 | +from django.db.models import Q, QuerySet |
3 | 4 | from guardian.shortcuts import get_perms
|
4 | 5 |
|
5 | 6 | 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() |
6 | 89 |
|
7 | 90 |
|
8 | 91 | class BaseModel(models.Model):
|
9 | 92 | """ """
|
10 | 93 |
|
11 | 94 | created_at = models.DateTimeField(auto_now_add=True)
|
12 | 95 | 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) |
13 | 134 |
|
14 | 135 | 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 |
17 | 152 |
|
18 | 153 | def __str__(self) -> str:
|
19 | 154 | """All django models should have this method."""
|
@@ -52,13 +187,16 @@ def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> b
|
52 | 187 | This method is used to determine if the user can perform
|
53 | 188 | CRUD operations or custom actions on the model instance.
|
54 | 189 | """
|
| 190 | + from ami.users.roles import BasicMember |
| 191 | + |
55 | 192 | project = self.get_project() if hasattr(self, "get_project") else None
|
56 | 193 | if not project:
|
57 | 194 | return False
|
58 | 195 | 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 |
60 | 199 | return True
|
61 |
| - |
62 | 200 | model = self._meta.model_name
|
63 | 201 | crud_map = {
|
64 | 202 | "create": f"create_{model}",
|
|
0 commit comments