diff --git a/ami/base/views.py b/ami/base/views.py index 23d3f4903..a4d7b1b17 100644 --- a/ami/base/views.py +++ b/ami/base/views.py @@ -64,23 +64,33 @@ class ProjectMixin: Mixin to help views extract the active project from the request. """ - require_project = False # Project is optional + require_project = False # Project required for all actions + require_project_for_list = True # Project required for list actions request: rest_framework.request.Request kwargs: dict # This is is generated by DRF from the URL pattern /api/projects/{project_id:int}/ def get_active_project(self) -> Project | None: """ Instance method wrapper around the standalone get_active_project function. - Raises Http404 if project_id is required but not provided or the project does not exist. + + Enforcement logic: + - If require_project is True, project_id is required for all actions. + - If require_project_for_list is True (default), project_id is required for list actions. + - Missing project_id when required raises ValidationError (400). + - Valid project_id pointing to nonexistent project raises Http404 (404). """ + action = getattr(self, "action", None) + required = self.require_project or (self.require_project_for_list and action == "list") + project = get_active_project( request=self.request, kwargs=self.kwargs, - required=self.require_project, + required=required, ) - if not project and self.require_project: - # project_id was required but not provided or invalid - raise Http404("Project ID is required but was not provided or is invalid") + if not project and required: + # Missing project_id is already caught by ValidationError in get_active_project(). + # This handles: valid project_id was provided but the project doesn't exist. + raise Http404("Project not found.") return project diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 9a2770ac8..73b3e5b45 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1330,6 +1330,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin): API endpoint that allows taxa to be viewed or edited. """ + require_project_for_list = False # Taxonomy is global, not per-project queryset = Taxon.objects.all().defer("notes") serializer_class = TaxonSerializer filter_backends = DefaultViewSetMixin.filter_backends + [ @@ -1609,6 +1610,7 @@ def list(self, request, *args, **kwargs): class TaxaListViewSet(viewsets.ModelViewSet, ProjectMixin): + require_project_for_list = False # Taxa lists are global queryset = TaxaList.objects.all() def get_queryset(self): @@ -1622,6 +1624,7 @@ def get_queryset(self): class TagViewSet(DefaultViewSet, ProjectMixin): + require_project_for_list = False # Tags include global tags queryset = Tag.objects.all() serializer_class = TagSerializer filterset_fields = ["taxa"] @@ -1678,6 +1681,7 @@ def get_serializer_class(self): class SummaryView(GenericAPIView, ProjectMixin): permission_classes = [IsActiveStaffOrReadOnly] + require_project = True # Unfiltered summary queries are too expensive @extend_schema(parameters=[project_id_doc_param]) def get(self, request): @@ -1686,43 +1690,30 @@ def get(self, request): """ user = request.user project = self.get_active_project() - if project: - data = { - "projects_count": Project.objects.visible_for_user( # type: ignore - user - ).count(), # @TODO filter by current user, here and everywhere! - "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(), - # "detections_count": Detection.objects.filter(occurrence__project=project).count(), - "occurrences_count": Occurrence.objects.visible_for_user(user) # type: ignore - .apply_default_filters(project=project, request=self.request) # type: ignore - .valid() - .filter(project=project) - .count(), # type: ignore - "taxa_count": Occurrence.objects.visible_for_user(user) # type: ignore - .apply_default_filters(project=project, request=self.request) # type: ignore - .unique_taxa(project=project) - .count(), - } - else: - data = { - "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(), - } + data = { + "projects_count": Project.objects.visible_for_user( # type: ignore + user + ).count(), # @TODO filter by current user, here and everywhere! + "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(), + # "detections_count": Detection.objects.filter(occurrence__project=project).count(), + "occurrences_count": Occurrence.objects.visible_for_user(user) # type: ignore + .apply_default_filters(project=project, request=self.request) # type: ignore + .valid() + .filter(project=project) + .count(), # type: ignore + "taxa_count": Occurrence.objects.visible_for_user(user) # type: ignore + .apply_default_filters(project=project, request=self.request) # type: ignore + .unique_taxa(project=project) + .count(), + } aliases = { "num_sessions": data["events_count"],