Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 2 additions & 7 deletions apps/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@
from apps.commons.views import (
DetailOnlyViewsetMixin,
MultipleIDViewsetMixin,
NestedOrganizationViewMixins,
NestedPeopleGroupViewMixins,
PeopleGroupRelatedViewset,
)
from apps.files.models import Image
from apps.files.views import ImageStorageView
Expand Down Expand Up @@ -881,11 +880,7 @@ def locations(self, request, *args, **kwargs):
)


class PeopleGroupLocationViewSet(
NestedOrganizationViewMixins,
NestedPeopleGroupViewMixins,
viewsets.ModelViewSet,
):
class PeopleGroupLocationViewSet(PeopleGroupRelatedViewset, viewsets.ModelViewSet):
serializer_class = PeopleGroupLocationSerializer

def get_permissions(self):
Expand Down
409 changes: 398 additions & 11 deletions apps/commons/views.py

Large diffs are not rendered by default.

12 changes: 2 additions & 10 deletions apps/files/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@
)
from apps.commons.permissions import IsOwner, ReadOnly, WillBeOwner
from apps.commons.utils import map_action_to_permission
from apps.commons.views import (
MultipleIDViewsetMixin,
NestedOrganizationViewMixins,
NestedPeopleGroupViewMixins,
)
from apps.commons.views import MultipleIDViewsetMixin, PeopleGroupRelatedViewset
from apps.organizations.models import Organization
from apps.organizations.permissions import HasOrganizationPermission
from apps.projects.models import Project
Expand Down Expand Up @@ -285,11 +281,7 @@ def create(self, request, *ar, **kw):
return super().create(request, *ar, **kw)


class PeopleGroupGalleryViewSet(
NestedOrganizationViewMixins,
NestedPeopleGroupViewMixins,
viewsets.ModelViewSet,
):
class PeopleGroupGalleryViewSet(PeopleGroupRelatedViewset, viewsets.ModelViewSet):
serializer_class = PeopleGroupImageSerializer

def get_permissions(self):
Expand Down
2 changes: 1 addition & 1 deletion apps/projects/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def has_object_permission(
if not project:
project = self.get_related_project(request, view)
if project and app:
request.user.has_perm(f"{app}.{codename}", project)
return request.user.has_perm(f"{app}.{codename}", project)
if project:
return request.user.has_perm(codename, project)
return False
Expand Down
174 changes: 109 additions & 65 deletions apps/projects/tests/views/test_project.py

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions apps/projects/tests/views/test_project_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ def test_create_project_header(self, role, expected_code):
"natural_ratio": faker.pyfloat(min_value=1.0, max_value=2.0),
}
response = self.client.post(
reverse("Project-header-list", args=(self.project.id,)),
reverse(
"Project-header-list",
args=(
self.organization.code,
self.project.id,
),
),
data=payload,
format="multipart",
)
Expand Down Expand Up @@ -105,7 +111,11 @@ def test_update_project_header(self, role, expected_code):
response = self.client.patch(
reverse(
"Project-header-detail",
args=(self.project.id, self.project.header_image.id),
args=(
self.organization.code,
self.project.id,
self.project.header_image.id,
),
),
data=payload,
format="multipart",
Expand Down Expand Up @@ -154,8 +164,8 @@ def test_delete_project_header(self, role, expected_code):
response = self.client.delete(
reverse(
"Project-header-detail",
args=(project.id, project.header_image.id),
)
args=(self.organization.code, project.id, project.header_image.id),
),
)
self.assertEqual(response.status_code, expected_code)
if expected_code == status.HTTP_204_NO_CONTENT:
Expand Down
8 changes: 5 additions & 3 deletions apps/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from apps.announcements.views import AnnouncementViewSet
from apps.commons.urls import (
organization_project_router_register,
organization_router_register,
project_router_register,
)
Expand Down Expand Up @@ -32,11 +33,10 @@
)

router = DefaultRouter()

organization_router_register(
router, r"location", GeneralLocationView, basename="General-location"
)
router.register(r"project", ProjectViewSet, basename="Project")
organization_router_register(router, r"project", ProjectViewSet, basename="Project")

project_router_register(
router, r"history", HistoricalProjectViewSet, basename="Project-versions"
Expand All @@ -63,7 +63,9 @@
router, r"announcement", AnnouncementViewSet, basename="Announcement"
)
project_router_register(router, r"image", ProjectImagesView, basename="Project-images")
project_router_register(router, r"header", ProjectHeaderView, basename="Project-header")
organization_project_router_register(
router, r"header", ProjectHeaderView, basename="Project-header"
)
project_router_register(
router, r"project-message", ProjectMessageViewSet, basename="ProjectMessage"
)
Expand Down
42 changes: 17 additions & 25 deletions apps/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
from apps.commons.utils import map_action_to_permission
from apps.commons.views import (
MultipleIDViewsetMixin,
NestedOrganizationViewMixins,
OrganizationRelatedViewset,
ProjectRelatedViewset,
)
from apps.files.models import Image
from apps.files.views import ImageStorageView
Expand Down Expand Up @@ -83,19 +84,26 @@
)


class ProjectViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet):
class ProjectViewSet(
MultipleIDViewsetMixin, OrganizationRelatedViewset, viewsets.ModelViewSet
):
"""Main endpoints for projects."""

class InfoDetails(enum.Enum):
SUMMARY = "summary"

queryset = Project.objects.all()
serializer_class = ProjectSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_class = ProjectFilter
ordering_fields = ["created_at", "updated_at"]
lookup_field = "id"
lookup_value_regex = "[^/]+"
multiple_lookup_fields = [(Project, "id")]

queryset_organization_field = "organizations"
multiple_lookup_fields = [
(Project, "id"),
]

def get_permissions(self):
codename = map_action_to_permission(self.action, "project")
Expand All @@ -112,7 +120,7 @@ def get_permissions(self):

def get_queryset(self) -> QuerySet:
return (
self.request.user.get_project_queryset()
self.organization_filter_queryset(self.request.user.get_project_queryset())
.select_related("header_image")
.prefetch_related(
"categories",
Expand All @@ -137,10 +145,6 @@ def get_serializer_class(self):
return ProjectLightSerializer
return self.serializer_class

def get_serializer_context(self):
"""Adds request to the serializer's context."""
return {"request": self.request}

def perform_create(self, serializer: ProjectSerializer):
project = serializer.save()
project.setup_permissions(self.request.user)
Expand Down Expand Up @@ -400,22 +404,10 @@ def similar(self, request, *args, **kwargs):
return Response(ProjectLightSerializer(queryset, many=True).data)


class ProjectHeaderView(MultipleIDViewsetMixin, ImageStorageView):
permission_classes = [
IsAuthenticatedOrReadOnly,
ProjectIsNotLocked,
ReadOnly
| IsOwner
| HasBasePermission("change_project", "projects")
| HasOrganizationPermission("change_project")
| HasProjectPermission("change_project"),
]
multiple_lookup_fields = [(Project, "project_id")]

def get_queryset(self):
if "project_id" in self.kwargs:
return Image.objects.filter(project_header__id=self.kwargs["project_id"])
return Image.objects.none()
class ProjectHeaderView(ProjectRelatedViewset, ImageStorageView):
queryset = Image.objects.all()
queryset_organization_field: str = "project_header__organizations"
queryset_project_field: str = "project_header"

@staticmethod
def upload_to(instance, filename) -> str:
Expand Down Expand Up @@ -975,7 +967,7 @@ def add_image_to_model(self, image, *args, **kwargs):
return None


class GeneralLocationView(NestedOrganizationViewMixins, viewsets.GenericViewSet):
class GeneralLocationView(OrganizationRelatedViewset):
http_method_names = ["get", "list"]

def list(self, request, *args, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion keycloak-lpi-theme
120 changes: 113 additions & 7 deletions services/crisalid/utils/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,119 @@
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly

from apps.accounts.permissions import HasBasePermission
from apps.commons.permissions import IsOwner, ReadOnly
from apps.commons.utils import map_action_to_permission
from apps.commons.views import OrganizationRelatedViewset
from apps.organizations.permissions import HasOrganizationPermission
from services.crisalid.models import Researcher


class NestedResearcherViewMixins:
def initial(self, request, *args, **kwargs):
self.researcher = get_object_or_404(
Researcher,
pk=kwargs["researcher_id"],
user__groups__in=(self.organization.get_users(),),
class ResearcherRelatedViewset(OrganizationRelatedViewset):
"""
A viewset for models related to a researcher.

This viewset should only be accessed through a URL containing the `researcher_id` and
`organization_code` kwargs.
e.g. `/v1/organizations/{organization_code}/researcher/{researcher_id}/my_model/`

The viewset automatically handles filtering using the request user's permissions,
and it provides the researcher in the serializer context.

Attributes :
------------
organization_code_url_kwarg: str (default: "organization_code")
The name of the URL kwarg containing the organization code.
researcher_id_url_kwarg: str (default: "researcher_id")
The name of the URL kwarg containing the researcher id.
queryset_organization_field: str (default: "researcher__organizations")
The name of the field to use for filtering the queryset by organization.
queryset_researcher_field: str (default: "researcher")
The name of the field to use for filtering the queryset by researcher.
read_only_permissions: bool (default: True)
Whether the viewset should use read-only permissions. This is useful when the
read permissions are handled at the instance level.
permissions_app_label: str (default: "")
The app label to use in the default permissions check
permissions_base_codename: str (default: "")
The base codename to use for generating the permissions to check. If not set,
the `permissions_codename` attribute will be used as the codename for all actions.
permissions_codename: str (default: "change_researcher")
The codename to use for the default permissions check if`permissions_base_codename`
is not set. This can be used if the same permission is used for all actions.
multiple_lookup_fields: list of tuple[HasMultipleIDs, str] (default: [])
Inherited from MultipleIDViewsetMixin. A list of tuples containing a model that
inherits from HasMultipleIDs and the name of the URL kwarg containing the id to
transform into the main id.
"""

researcher_id_url_kwarg: str = "researcher_id"
queryset_organization_field: str = "researcher__user__groups__organizations"
queryset_researcher_field: str = "researcher"

read_only_permissions: bool = True
permissions_app_label: str = "crisalid"
permissions_base_codename: str = ""
permissions_codename: str = "change_researcher"

def get_permissions(self):
if self.permissions_base_codename:
codename = map_action_to_permission(
self.action, self.permissions_base_codename
)
else:
codename = self.permissions_codename
if codename and self.permissions_app_label:
if self.read_only_permissions:
permissions = [
IsAuthenticatedOrReadOnly,
IsOwner
| ReadOnly
| HasBasePermission(codename, self.permissions_app_label)
| HasOrganizationPermission(codename),
]
else:
permissions = [
IsAuthenticated,
IsOwner
| HasBasePermission(codename, self.permissions_app_label)
| HasOrganizationPermission(codename),
]
return permissions
return super().get_permissions()

def researcher_filter_queryset(self, queryset: "QuerySet") -> "QuerySet":
"""
Filter the given queryset by the researcher specified in the URL.
"""
return self.request.user.get_user_related_queryset(
queryset.filter(**{self.queryset_researcher_field: self.researcher}),
f"{self.queryset_researcher_field}__user",
)
super().initial(request, *args, **kwargs)

def get_queryset(self):
"""
Return the queryset for this viewset, filtered by the researcher and the
organization specified in the URL.
"""
return self.researcher_filter_queryset(super().get_queryset())

def get_serializer_context(self):
return {
**super().get_serializer_context(),
"researcher": self.researcher,
}

@property
def researcher(self) -> Researcher:
if not hasattr(self, "_researcher"):
if self.researcher_id_url_kwarg not in self.kwargs:
raise ValueError(
f"URL kwarg '{self.researcher_id_url_kwarg}' is required for a"
f" viewset based on ResearcherRelatedViewset."
)
self._researcher = get_object_or_404(
Researcher, id=self.kwargs[self.researcher_id_url_kwarg]
)
return self._researcher
Loading
Loading