diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 5d54fba7..ef05894c 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -982,3 +982,39 @@ def __init__(self, invitation): def has_perm(self, perm, obj=None): return perm == "accounts.add_projectuser" + + +class InternalAdmin(AnonymousUser): + """internal admim user (he have access to all models)""" + + def get_project_queryset(self): + return Project.objects.all() + + def get_news_queryset(self): + return News.objects.all() + + def get_event_queryset(self): + return Event.objects.all() + + def get_instruction_queryset(self): + return Instruction.objects.all() + + def get_user_queryset(self): + return ProjectUser.objects.all() + + def get_people_group_queryset(self): + return PeopleGroup.objects.all() + + def _query_function(self, queryset, *ar, **kw): + return queryset + + get_project_related_queryset = _query_function + get_user_related_queryset = _query_function + get_people_group_related_queryset = _query_function + get_news_related_queryset = _query_function + get_instruction_related_queryset = _query_function + get_event_related_queryset = _query_function + + def get_related_organizations(self) -> list["Organization"]: + """Return the organizations related to this model.""" + return list(Organization.objects.all()) diff --git a/apps/accounts/permissions.py b/apps/accounts/permissions.py index 19ebc362..0aa2ae5e 100644 --- a/apps/accounts/permissions.py +++ b/apps/accounts/permissions.py @@ -7,6 +7,12 @@ from apps.commons.permissions import IgnoreCall +class ProjectNestedPermission(permissions.BasePermission): + def has_permission(self, request: Request, view: GenericViewSet) -> bool: + """check "project" from NestedProjectMixins""" + return request.user.get_project_queryset().contains(view.project) + + def HasBasePermission( # noqa: N802 codename: str, app: str = "" ) -> permissions.BasePermission: diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 5b883309..6d8440fd 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -111,8 +111,7 @@ class UserLighterSerializer(serializers.ModelSerializer): profile_picture = PrivacySettingProtectedMethodField( privacy_field="profile_picture" ) - is_manager = serializers.BooleanField(required=False, read_only=True) - is_leader = serializers.BooleanField(required=False, read_only=True) + role = serializers.CharField(required=False, read_only=True) class Meta: model = ProjectUser @@ -126,8 +125,7 @@ class Meta: "pronouns", "job", "profile_picture", - "is_manager", - "is_leader", + "role", ] fields = read_only_fields @@ -144,8 +142,7 @@ def to_representation(self, instance: ProjectUser) -> dict[str, Any]: return super().to_representation(instance) return { **AnonymousUser.serialize(with_permissions=False), - "is_manager": False, - "is_leader": False, + "role": None, } @@ -530,13 +527,6 @@ class PeopleGroupSerializer( roles = serializers.SlugRelatedField( many=True, slug_field="name", read_only=True, source="groups" ) - team = PeopleGroupAddTeamMembersSerializer(required=False, write_only=True) - featured_projects = serializers.PrimaryKeyRelatedField( - many=True, - write_only=True, - required=False, - queryset=Project.objects.all(), - ) tags = TagRelatedField(many=True, required=False) sdgs = serializers.ListField( child=serializers.IntegerField(min_value=1, max_value=17), @@ -558,12 +548,6 @@ def get_hierarchy(self, obj: PeopleGroup) -> list[dict[str, str | int]]: ) return [{"order": i, **h} for i, h in enumerate(hierarchy[::-1])] - def validate_featured_projects(self, projects: list[Project]) -> list[Project]: - request = self.context.get("request") - if not all(request.user.can_see_project(project) for project in projects): - raise FeaturedProjectPermissionDeniedError - return projects - def validate_organization(self, value): if self.instance and self.instance.organization != value: raise GroupOrganizationChangeError @@ -604,31 +588,18 @@ def validate_parent(self, value): return value def create(self, validated_data): - team = validated_data.pop("team", {}) - featured_projects = validated_data.pop("featured_projects", []) locations = validated_data.pop("locations", []) - people_group = super(PeopleGroupSerializer, self).create(validated_data) - PeopleGroupAddTeamMembersSerializer().create( - {"people_group": people_group, **team} - ) - PeopleGroupAddFeaturedProjectsSerializer().create( - { - "people_group": people_group, - "featured_projects": featured_projects, - } - ) + people_group = super().create(validated_data) PeopleGroupAddLocationsSerializer().create( {"people_group": people_group, "locations": locations} ) return people_group def update(self, instance, validated_data): - validated_data.pop("team", {}) - validated_data.pop("featured_projects", []) validated_data.pop("locations", None) - return super(PeopleGroupSerializer, self).update(instance, validated_data) + return super().update(instance, validated_data) class Meta: model = PeopleGroup @@ -649,8 +620,6 @@ class Meta: "tags", "locations", "publication_status", - "team", - "featured_projects", ] @@ -825,8 +794,6 @@ def to_representation(self, instance): return { **AnonymousUser.serialize(with_permissions=False), "current_org_role": None, - "is_manager": False, - "is_leader": False, } def _validate_role( @@ -964,7 +931,7 @@ def create(self, validated_data): "natural_ratio", ] } - instance = super(UserSerializer, self).create(validated_data) + instance = super().create(validated_data) if profile_picture["file"]: image = Image( name=profile_picture["file"].name, diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 6d74eb88..904e7abe 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -317,17 +317,18 @@ def test_create_people_group(self, role, expected_code): "description": faker.text(), "email": faker.email(), "parent": parent.id, - "team": { - "members": [m.id for m in members], - "managers": [r.id for r in managers], - "leaders": [r.id for r in leaders], - }, - "featured_projects": [p.pk for p in projects], "locations": locations, } response = self.client.post( reverse("PeopleGroup-list", args=(organization.code,)), payload ) + team = { + "members": [m.id for m in members], + "managers": [r.id for r in managers], + "leaders": [r.id for r in leaders], + } + featured_projects = {"featured_projects": [p.pk for p in projects]} + self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_201_CREATED: self.assertEqual(response.data["name"], payload["name"]) @@ -336,6 +337,24 @@ def test_create_people_group(self, role, expected_code): self.assertEqual(response.data["organization"], organization.code) self.assertEqual(response.data["hierarchy"][0]["id"], parent.id) self.assertEqual(response.data["hierarchy"][0]["slug"], parent.slug) + + rsp2 = self.client.post( + reverse( + "PeopleGroup-add-member", + args=(organization.code, response.data["id"]), + ), + team, + ) + self.assertEqual(rsp2.status_code, status.HTTP_204_NO_CONTENT) + rsp3 = self.client.post( + reverse( + "PeopleGroup-add-featured-project", + args=(organization.code, response.data["id"]), + ), + featured_projects, + ) + self.assertEqual(rsp3.status_code, status.HTTP_204_NO_CONTENT) + people_group = PeopleGroup.objects.get(id=response.json()["id"]) for member in members: self.assertIn(member, people_group.members.all()) @@ -749,7 +768,7 @@ def test_assign_role_on_project_group_member_changer(self, project_role): people_group.leaders.add(self.user_3) payload = {project_role: [people_group.id]} response = self.client.post( - reverse("Project-add-member", args=(self.project.id,)), payload + reverse("Project-member-add-member", args=(self.project.id,)), payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.project.refresh_from_db() @@ -757,7 +776,7 @@ def test_assign_role_on_project_group_member_changer(self, project_role): self.assertIn(user, getattr(self.project, f"{project_role}_users").all()) payload = {"people_groups": [people_group.id]} response = self.client.post( - reverse("Project-remove-member", args=(self.project.id,)), payload + reverse("Project-member-remove-member", args=(self.project.id,)), payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.project.refresh_from_db() @@ -1039,19 +1058,21 @@ def setUpTestData(cls): cls.organization = OrganizationFactory() cls.superadmin = UserFactory(groups=[get_superadmins_group()]) + def members_by_role(self, members: list, role: GroupData.Role): + return [member["id"] for member in members if member["role"] == role.value] + def test_annotate_members(self): people_group = PeopleGroupFactory( publication_status=PeopleGroup.PublicationStatus.PUBLIC, organization=self.organization, ) - leaders_managers = UserFactory.create_batch(2) + leaders = UserFactory.create_batch(2) managers = UserFactory.create_batch(2) - leaders_members = UserFactory.create_batch(2) members = UserFactory.create_batch(2) - people_group.managers.add(*managers, *leaders_managers) - people_group.members.add(*members, *leaders_members) - people_group.leaders.add(*leaders_managers, *leaders_members) + people_group.managers.set(managers) + people_group.members.set(members) + people_group.leaders.set(leaders) response = self.client.get( reverse( @@ -1064,33 +1085,20 @@ def test_annotate_members(self): content = response.json() results = content["results"] - batch_1 = results[:2] - batch_1_ids = [user["id"] for user in batch_1] - leaders_managers_ids = [user.id for user in leaders_managers] - self.assertEqual(leaders_managers_ids.sort(), batch_1_ids.sort()) - self.assertTrue(all(user["is_manager"] is True for user in batch_1)) - self.assertTrue(all(user["is_leader"] is True for user in batch_1)) - - batch_2 = results[2:4] - batch_2_ids = [user["id"] for user in batch_2] - leaders_members_ids = [user.id for user in leaders_members] - self.assertEqual(leaders_members_ids.sort(), batch_2_ids.sort()) - self.assertTrue(all(user["is_manager"] is False for user in batch_2)) - self.assertTrue(all(user["is_leader"] is True for user in batch_2)) - - batch_3 = results[4:6] - batch_3_ids = [user["id"] for user in batch_3] - managers_ids = [user.id for user in managers] - self.assertEqual(managers_ids.sort(), batch_3_ids.sort()) - self.assertTrue(all(user["is_manager"] is True for user in batch_3)) - self.assertTrue(all(user["is_leader"] is False for user in batch_3)) - - batch_4 = results[6:] - batch_4_ids = [user["id"] for user in batch_4] - members_ids = [user.id for user in members] - self.assertEqual(members_ids.sort(), batch_4_ids.sort()) - self.assertTrue(all(user["is_manager"] is False for user in batch_4)) - self.assertTrue(all(user["is_leader"] is False for user in batch_4)) + self.assertListEqual( + sorted(self.members_by_role(results, GroupData.Role.MANAGERS)), + sorted([user.id for user in managers]), + ) + + self.assertListEqual( + sorted(self.members_by_role(results, GroupData.Role.LEADERS)), + sorted([user.id for user in leaders]), + ) + + self.assertListEqual( + sorted(self.members_by_role(results, GroupData.Role.MEMBERS)), + sorted([user.id for user in members]), + ) def test_root_group_creation(self): organization = OrganizationFactory() diff --git a/apps/accounts/tests/views/test_user_publication_status.py b/apps/accounts/tests/views/test_user_publication_status.py index bb2834f0..c832062b 100644 --- a/apps/accounts/tests/views/test_user_publication_status.py +++ b/apps/accounts/tests/views/test_user_publication_status.py @@ -133,40 +133,46 @@ def test_list_users(self, role, expected_users): @parameterized.expand( [ - (TestRoles.ANONYMOUS, ("public", None, None)), - (TestRoles.DEFAULT, ("public", None, None)), + (TestRoles.ANONYMOUS, ("public",)), + (TestRoles.DEFAULT, ("public",)), (TestRoles.SUPERADMIN, ("public", "private", "org")), (TestRoles.ORG_ADMIN, ("public", "private", "org")), (TestRoles.ORG_FACILITATOR, ("public", "private", "org")), - (TestRoles.ORG_USER, ("public", "org", None)), - (TestRoles.ORG_VIEWER, ("public", "org", None)), + (TestRoles.ORG_USER, ("public", "org")), + (TestRoles.ORG_VIEWER, ("public", "org")), ] ) def test_view_project_members(self, role, expected_users): organization = self.organization user = self.get_parameterized_test_user(role, instances=[organization]) self.client.force_authenticate(user) - response = self.client.get(reverse("Project-detail", args=(self.project.pk,))) + response = self.client.get( + reverse("Project-member-list", args=(self.project.pk,)) + ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json() - self.assertEqual(len(content["team"]["members"]), len(expected_users)) + + members = content["results"] + + self.assertEqual(len(members), len(expected_users)) self.assertEqual( - {user["id"] for user in content["team"]["members"]}, + {user["id"] for user in members}, { - self.users[user_type].id if user_type in expected_users else None + self.users[user_type].id for user_type in self.users.keys() + if user_type in expected_users }, ) @parameterized.expand( [ - (TestRoles.ANONYMOUS, ("public", None, None)), - (TestRoles.DEFAULT, ("public", None, None)), + (TestRoles.ANONYMOUS, ("public",)), + (TestRoles.DEFAULT, ("public",)), (TestRoles.SUPERADMIN, ("public", "private", "org")), (TestRoles.ORG_ADMIN, ("public", "private", "org")), (TestRoles.ORG_FACILITATOR, ("public", "private", "org")), - (TestRoles.ORG_USER, ("public", "org", None)), - (TestRoles.ORG_VIEWER, ("public", "org", None)), + (TestRoles.ORG_USER, ("public", "org")), + (TestRoles.ORG_VIEWER, ("public", "org")), ] ) def test_view_people_group_members(self, role, expected_users): @@ -185,8 +191,9 @@ def test_view_people_group_members(self, role, expected_users): self.assertEqual( {user["id"] for user in content}, { - self.users[user_type].id if user_type in expected_users else None + self.users[user_type].id for user_type in self.users.keys() + if user_type in expected_users }, ) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index c3c6f713..f8553768 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -31,7 +31,7 @@ class AnnouncementViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): lookup_field = "id" lookup_value_regex = "[0-9]+" filter_backends = [DjangoFilterBackend, OrderingFilter] - ordering_fields = ["updated_at", "deadline"] + ordering_fields = ["updated_at", "created_at", "deadline"] ordering = ["updated_at"] permission_classes = [ IsAuthenticatedOrReadOnly, @@ -73,7 +73,7 @@ def apply(self, request, **kwargs): @method_decorator(clear_cache_with_key("announcements_list_cache")) def dispatch(self, request, *args, **kwargs): - return super(AnnouncementViewSet, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) class ReadAnnouncementViewSet(AnnouncementViewSet): @@ -85,4 +85,4 @@ class ReadAnnouncementViewSet(AnnouncementViewSet): ) ) def list(self, request, *args, **kwargs): - return super(ReadAnnouncementViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) diff --git a/apps/commons/filters.py b/apps/commons/filters.py index a5953ca2..224585a4 100644 --- a/apps/commons/filters.py +++ b/apps/commons/filters.py @@ -11,7 +11,7 @@ class MultiValueCharFilter(filters.BaseCSVFilter, filters.CharFilter): def filter(self, query_set: QuerySet, value: str) -> QuerySet: # noqa: A003 # value is either a list or an 'empty' value if value: - return super(MultiValueCharFilter, self).filter(query_set, value) + return super().filter(query_set, value) return query_set @@ -37,6 +37,17 @@ def filter(self, queryset: QuerySet, value: str) -> QuerySet: # noqa: A003 return queryset +class ProjectUserMultipleIDFilter(MultiValueCharFilter): + def __init__(self, group_id_field: str = "id", *args, **kwargs): + self.group_id_field = group_id_field + super().__init__(*args, **kwargs) + + def filter(self, queryset: QuerySet, value: str) -> QuerySet: # noqa: A003 + if value: + return super().filter(queryset, ProjectUser.get_main_ids(value)) + return queryset + + class UnaccentSearchFilter(SearchFilter): class PostgresUnaccent(Func): function = "UNACCENT" diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index fbcd19de..e4c65ecf 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from contextlib import suppress from copy import copy +from functools import cached_property from typing import TYPE_CHECKING, Any, Optional, Self from django.contrib.auth.models import Group, Permission @@ -368,7 +369,7 @@ def __init__(self, *args, **kwargs): self._original_slug_fields_value = { field: getattr(self, field, "") for field in self.slugified_fields } - super(HasMultipleIDs, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def save(self, *args, **kwargs): if not self.slug or any( @@ -457,6 +458,20 @@ def get_related_module(self): return get_module(type(self)) + def modules_by_user(self, user: "ProjectUser"): + """return modules wrapped by user""" + modules_manager = self.get_related_module() + return modules_manager(self, user) + + @cached_property + def modules(self): + """return modules from self for any user(internalAdmin models)""" + from apps.accounts.models import InternalAdmin + + internaladmin = InternalAdmin() + + return self.modules_by_user(internaladmin) + class HasEmbedding: def vectorize(self): diff --git a/apps/commons/test.py b/apps/commons/test.py index e877fe6d..8d4bb097 100644 --- a/apps/commons/test.py +++ b/apps/commons/test.py @@ -3,6 +3,7 @@ import logging import os import uuid +from typing import Any from unittest import skipUnless, util from django.conf import settings @@ -255,7 +256,7 @@ def get_oversized_test_image(cls) -> Image: return image def assertApiValidationError( # noqa: N802 - self, response, messages: dict[str, list[str]] | None = None + self, response, messages: Any | None = None ): content = response.json() self.assertEqual(content["type"], ExceptionType.VALIDATION.value) diff --git a/apps/commons/tests/test_process_text.py b/apps/commons/tests/test_process_text.py index 272ff35f..a3c3a951 100644 --- a/apps/commons/tests/test_process_text.py +++ b/apps/commons/tests/test_process_text.py @@ -651,18 +651,18 @@ def test_create_project(self): "is_shareable": faker.boolean(), "purpose": faker.sentence(), "organizations_codes": [self.organization.code], - "images_ids": [], } response = self.client.post(reverse("Project-list"), data=payload) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - content = response.json() - self.assertEqual(len(content["images"]), 2) - project_id = content["id"] - for image in content["images"]: - image_id = image["id"] + + project = Project.objects.get(id=response.json()["id"]) + images = project.images.all() + + self.assertEqual(len(images), 2) + for image in images: self.assertIn( - reverse("Project-images-detail", args=(project_id, image_id)), - content["description"], + reverse("Project-images-detail", args=(project.id, image.id)), + project.description, ) def test_update_project(self): @@ -677,13 +677,15 @@ def test_update_project(self): reverse("Project-detail", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json() - self.assertEqual(len(content["images"]), 3) - for image in content["images"]: - image_id = image["id"] + + project = Project.objects.get(id=response.json()["id"]) + images = project.images.all() + + self.assertEqual(len(images), 3) + for image in images: self.assertIn( - reverse("Project-images-detail", args=(self.project.id, image_id)), - content["description"], + reverse("Project-images-detail", args=(project.id, image.id)), + project.description, ) def test_create_blog_entry(self): @@ -936,7 +938,6 @@ def test_html_escape_char(self): "is_shareable": faker.boolean(), "purpose": purpose, "organizations_codes": [self.organization.code], - "images_ids": [], } response = self.client.post(reverse("Project-list"), data=payload) self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/apps/commons/views.py b/apps/commons/views.py index 40dfe801..aff13401 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -3,7 +3,9 @@ from rest_framework.response import Response from rest_framework.settings import api_settings +from apps.accounts.permissions import ProjectNestedPermission from apps.organizations.models import Organization +from apps.projects.models import Project from .mixins import HasMultipleIDs @@ -156,6 +158,21 @@ def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) +class NestedProjectViewMixins: + project: Project + + def initial(self, request, *args, **kwargs): + self.project = get_object_or_404( + Project.objects.slug_or_id(kwargs["project_id"]), + ) + + super().initial(request, *args, **kwargs) + + def get_permissions(self): + """add check nested project""" + return [ProjectNestedPermission(), *super().get_permissions()] + + class NestedPeopleGroupViewMixins: def initial(self, request, *args, **kwargs): self.people_group = get_object_or_404( diff --git a/apps/feedbacks/filters.py b/apps/feedbacks/filters.py index 4e244963..adb07723 100644 --- a/apps/feedbacks/filters.py +++ b/apps/feedbacks/filters.py @@ -2,7 +2,7 @@ from apps.commons.filters import UserMultipleIDFilter -from .models import Review +from .models import Comment, Review class ReviewFilter(filters.FilterSet): @@ -12,3 +12,9 @@ class ReviewFilter(filters.FilterSet): class Meta: model = Review fields = ["project", "reviewer"] + + +class CommentFilter(filters.FilterSet): + class Meta: + model = Comment + fields = ("id",) diff --git a/apps/feedbacks/serializers.py b/apps/feedbacks/serializers.py index afe16f60..d3dbf95c 100644 --- a/apps/feedbacks/serializers.py +++ b/apps/feedbacks/serializers.py @@ -61,6 +61,17 @@ def get_related_project(self) -> Optional["Project"]: return self.validated_data["project"] return None + def create(self, validated_data): + project = validated_data["project"] + follower = validated_data["follower"] + + # if already follo ignore + follow, _ = Follow.objects.get_or_create( + project=project, + follower=follower, + ) + return follow + class UserFollowManySerializer(serializers.Serializer): """Used to follow several projects at once.""" diff --git a/apps/feedbacks/views.py b/apps/feedbacks/views.py index 22cfe46c..6505f6dc 100644 --- a/apps/feedbacks/views.py +++ b/apps/feedbacks/views.py @@ -7,6 +7,7 @@ from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.response import Response @@ -23,7 +24,7 @@ from apps.projects.models import Project from apps.projects.permissions import HasProjectPermission -from .filters import ReviewFilter +from .filters import CommentFilter, ReviewFilter from .models import Comment, Follow, Review from .permissions import IsReviewable from .serializers import ( @@ -36,8 +37,9 @@ class ReviewViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): serializer_class = ReviewSerializer - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_class = ReviewFilter + ordering_fields = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[0-9]+" multiple_lookup_fields = [(ProjectUser, "user_id"), (Project, "project_id")] @@ -154,7 +156,9 @@ class ProjectFollowViewSet(FollowViewSet): class CommentViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): serializer_class = CommentSerializer - filter_backends = [DjangoFilterBackend] + filterset_class = CommentFilter + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering_fields = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[0-9]+" multiple_lookup_fields = [(Project, "project_id")] diff --git a/apps/invitations/views.py b/apps/invitations/views.py index dca9dc5b..4160849c 100644 --- a/apps/invitations/views.py +++ b/apps/invitations/views.py @@ -88,7 +88,6 @@ def perform_create(self, serializer): class AccessRequestViewSet(CreateListModelViewSet): serializer_class = AccessRequestSerializer - ordering_fields = ["status", "created_at"] filterset_class = AccessRequestFilter diff --git a/apps/modules/__init__.py b/apps/modules/__init__.py index 98a20100..38b9d7e4 100644 --- a/apps/modules/__init__.py +++ b/apps/modules/__init__.py @@ -1,3 +1,4 @@ from .group import PeopleGroupModules +from .project import ProjectModules -__all__ = ["PeopleGroupModules"] +__all__ = ["PeopleGroupModules", "ProjectModules"] diff --git a/apps/modules/base.py b/apps/modules/base.py index 540539f4..8907ca4d 100644 --- a/apps/modules/base.py +++ b/apps/modules/base.py @@ -5,6 +5,8 @@ from django.db import models from drf_spectacular.utils import OpenApiParameter +from apps.accounts.models import ProjectUser + IGNORE_MODULES_FUNCTION = "IGNORE_MODULES_FUNCTION" @@ -17,14 +19,14 @@ def ignore_method(method): class AbstractModules: """abstract class for modules/queryset declarations""" - def __init__(self, instance, /, user, **kw): + def __init__(self, instance, /, user: ProjectUser, **kw): self.instance = instance self.user = user @classmethod @ignore_method @cache - def all_modules(cls) -> tuple[str, Callable]: + def all_modules(cls) -> tuple[tuple[str, Callable]]: modules_list = [] def predicate(item): @@ -44,7 +46,9 @@ def predicate(item): @classmethod @ignore_method @cache - def modules(cls, modules_keys: tuple[str] | None = None) -> tuple[str, Callable]: + def modules( + cls, modules_keys: tuple[str] | None = None + ) -> tuple[tuple[str, Callable]]: modules_list = [] for name, func in cls.all_modules(): @@ -80,7 +84,7 @@ def ApiParameter(cls, **kw): # noqa: N802 ) -_modules: dict[models.Model] = {} +_modules: dict[models.Model, AbstractModules] = {} def register_module(model: models.Model): diff --git a/apps/modules/group.py b/apps/modules/group.py index dbc01d15..6604fe43 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -1,6 +1,7 @@ from django.db.models import Case, Prefetch, Q, QuerySet, Value, When from apps.accounts.models import PeopleGroup, PeopleGroupLocation, ProjectUser +from apps.commons.models import GroupData from apps.files.models import PeopleGroupImage from apps.modules.base import AbstractModules, register_module from apps.newsfeed.models import Event, EventLocation, NewsLocation @@ -18,22 +19,28 @@ def members(self) -> QuerySet[ProjectUser]: "skills", queryset=Skill.objects.select_related("tag") ) + leaders = self.instance.leaders.all() + managers = self.instance.managers.all() + members = self.instance.members.all() + + all_members = leaders | managers | members return ( - self.instance.get_all_members() - .distinct() - .annotate( - is_leader=Case( - When(id__in=self.instance.leaders.all(), then=True), - default=Value(False), - ) - ) + all_members.distinct() + .filter(pk__in=self.user.get_user_queryset()) .annotate( - is_manager=Case( - When(id__in=self.instance.managers.all(), then=True), - default=Value(False), - ) + role=Case( + When(pk__in=leaders, then=Value(GroupData.Role.LEADERS)), + When(pk__in=managers, then=Value(GroupData.Role.MANAGERS)), + When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), + ), + # add sort order priority (first leader, manager and members) + priority_role_order=Case( + When(pk__in=leaders, then=1), + When(pk__in=managers, then=2), + When(pk__in=members, then=3), + ), ) - .order_by("-is_leader", "-is_manager") + .order_by("priority_role_order") .prefetch_related(skills_prefetch, "groups") ) diff --git a/apps/modules/project.py b/apps/modules/project.py new file mode 100644 index 00000000..9c7e8aa5 --- /dev/null +++ b/apps/modules/project.py @@ -0,0 +1,116 @@ +from django.db.models import Case, QuerySet, Value, When + +from apps.accounts.models import PeopleGroup, ProjectUser +from apps.announcements.models import Announcement +from apps.commons.models import GroupData +from apps.feedbacks.models import Comment, Review +from apps.files.models import AttachmentFile, AttachmentLink +from apps.modules.base import AbstractModules, register_module +from apps.projects.models import ( + BlogEntry, + Goal, + Location, + Project, + ProjectMessage, +) + + +@register_module(Project) +class ProjectModules(AbstractModules): + instance: Project + + def members(self) -> QuerySet[ProjectUser]: + # get all members and annote role + owners = self.instance.owners.all() + members = self.instance.members.all() + reviewers = self.instance.reviewers.all() + + # union all and filter by request.user + all_members = owners | members | reviewers + return ( + all_members.distinct() + .filter(pk__in=self.user.get_user_queryset()) + .annotate( + role=Case( + When(pk__in=owners, then=Value(GroupData.Role.OWNERS)), + When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), + When(pk__in=reviewers, then=Value(GroupData.Role.REVIEWERS)), + ), + # add sort order priority (first leader, manager and members) + priority_role_order=Case( + When(pk__in=owners, then=1), + When(pk__in=members, then=2), + When(pk__in=reviewers, then=3), + ), + ) + .order_by("priority_role_order") + .distinct() + ) + + def groups(self) -> QuerySet[PeopleGroup]: + # get all members and annote role + owner_groups = self.instance.owner_groups.all() + member_groups = self.instance.member_groups.all() + reviewer_groups = self.instance.reviewer_groups.all() + + # union all and filter by request.user + all_members = owner_groups | member_groups | reviewer_groups + return ( + all_members.distinct() + .filter(pk__in=self.user.get_people_group_queryset()) + .annotate( + role=Case( + When(pk__in=owner_groups, then=Value(GroupData.Role.OWNER_GROUPS)), + When( + pk__in=member_groups, then=Value(GroupData.Role.MEMBER_GROUPS) + ), + When( + pk__in=reviewer_groups, + then=Value(GroupData.Role.REVIEWER_GROUPS), + ), + ), + # add sort order priority (first leader, manager and members) + priority_role_order=Case( + When(pk__in=owner_groups, then=1), + When(pk__in=member_groups, then=2), + When(pk__in=reviewer_groups, then=3), + ), + ) + .order_by("priority_role_order") + .distinct() + ) + + def linked_projects(self) -> QuerySet[Project]: + return self.instance.linked_projects.filter( + project__in=self.user.get_project_queryset() + ) + + def similars(self) -> QuerySet[Project]: + return self.instance.similars().filter(pk__in=self.user.get_project_queryset()) + + def locations(self) -> QuerySet[Location]: + return self.instance.locations.all() + + def comments(self) -> QuerySet[Comment]: + return self.instance.comments.all() + + def goals(self) -> QuerySet[Goal]: + return self.instance.goals.all() + + def blogs(self) -> QuerySet[BlogEntry]: + return self.instance.blog_entries.all() + + def files(self) -> QuerySet[AttachmentFile]: + return self.instance.files.all() + + def links(self) -> QuerySet[AttachmentLink]: + return self.instance.links.all() + + def announcements(self) -> QuerySet[Announcement]: + return self.instance.announcements.all() + + def reviews(self) -> QuerySet[Review]: + return self.instance.reviews.all() + + def messages(self) -> QuerySet[ProjectMessage]: + return self.instance.messages.all() diff --git a/apps/modules/serializers.py b/apps/modules/serializers.py index 33607207..a595ff9c 100644 --- a/apps/modules/serializers.py +++ b/apps/modules/serializers.py @@ -1,3 +1,5 @@ +from functools import cached_property + from django.http import QueryDict from rest_framework import serializers @@ -7,9 +9,8 @@ class ModulesSerializers(serializers.ModelSerializer): modules = serializers.SerializerMethodField() - def __init__(self, *ar, **kw): - super().__init__(*ar, **kw) - + @cached_property + def __modules_keys(self): if "modules_keys" not in self.context: request = self.context.get("request") query = request.query_params if request else QueryDict() @@ -24,10 +25,9 @@ def __init__(self, *ar, **kw): self.context["modules_keys"] = ( tuple(modules_keys) if modules_keys is not None else None ) + return self.context["modules_keys"] def get_modules(self, instance): request = self.context.get("request") - modules_keys = self.context.get("modules_keys") - modules_manager = instance.get_related_module() - return modules_manager(instance, user=request.user).count(modules_keys) + return instance.modules_by_user(request.user).count(self.__modules_keys) diff --git a/apps/notifications/tests/tasks/test_add_members_notifications.py b/apps/notifications/tests/tasks/test_add_members_notifications.py index b823826d..1540e27e 100644 --- a/apps/notifications/tests/tasks/test_add_members_notifications.py +++ b/apps/notifications/tests/tasks/test_add_members_notifications.py @@ -49,7 +49,7 @@ def test_notification_task_called(self, notification_task): member = UserFactory() payload = {GroupData.Role.MEMBERS: [member.id]} response = self.client.post( - reverse("Project-add-member", args=(project.id,)), data=payload + reverse("Project-member-add-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) notification_task.assert_called_once_with( @@ -70,7 +70,7 @@ def test_group_notification_task_called(self, notification_task): group = PeopleGroupFactory(organization=self.organization) payload = {GroupData.Role.MEMBER_GROUPS: [group.id]} response = self.client.post( - reverse("Project-add-member", args=(project.id,)), data=payload + reverse("Project-member-add-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) notification_task.assert_called_once_with( diff --git a/apps/notifications/tests/tasks/test_remove_members_notifications.py b/apps/notifications/tests/tasks/test_remove_members_notifications.py index f1f868d6..684cb703 100644 --- a/apps/notifications/tests/tasks/test_remove_members_notifications.py +++ b/apps/notifications/tests/tasks/test_remove_members_notifications.py @@ -48,7 +48,7 @@ def test_notification_task_called(self, notification_task): project.members.add(member) payload = {"users": [member.id]} response = self.client.post( - reverse("Project-remove-member", args=(project.id,)), data=payload + reverse("Project-member-remove-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) notification_task.assert_called_once_with(project.pk, member.pk, owner.pk) @@ -68,7 +68,7 @@ def test_group_notification_task_called(self, notification_task): project.member_groups.add(group) payload = {"people_groups": [group.id]} response = self.client.post( - reverse("Project-remove-member", args=(project.id,)), data=payload + reverse("Project-member-remove-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) notification_task.assert_called_once_with(project.pk, group.pk, owner.pk) diff --git a/apps/notifications/tests/tasks/test_update_members_notifications.py b/apps/notifications/tests/tasks/test_update_members_notifications.py index 2980b852..b020ee43 100644 --- a/apps/notifications/tests/tasks/test_update_members_notifications.py +++ b/apps/notifications/tests/tasks/test_update_members_notifications.py @@ -47,7 +47,7 @@ def test_notification_task_called(self, notification_task): project.owners.add(member) payload = {GroupData.Role.MEMBERS: [member.id]} response = self.client.post( - reverse("Project-add-member", args=(project.id,)), data=payload + reverse("Project-member-add-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) notification_task.assert_called_once_with( diff --git a/apps/projects/filters.py b/apps/projects/filters.py index dd4cb47b..8a1f5ef0 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -102,3 +102,19 @@ def filter_organizations(self, queryset, name, value): return queryset.filter( project__organizations__code__in=get_below_hierarchy_codes(value) ).distinct() + + +class ProjectMembersFilter(filters.FilterSet): + role = MultiValueCharFilter() + + class Meta: + model = ProjectUser + fields = ("role",) + + +class ProjectGroupsFilter(filters.FilterSet): + role = MultiValueCharFilter() + + class Meta: + model = PeopleGroup + fields = ("role",) diff --git a/apps/projects/models.py b/apps/projects/models.py index dde29d26..e0ab3d80 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -20,12 +20,15 @@ from apps.commons.enums import SDG, Language from apps.commons.mixins import ( DuplicableModel, + HasEmbedding, HasMultipleIDs, HasOwner, HasPermissionsSetup, + HasRelatedModules, ProjectRelated, ) from apps.commons.models import GroupData +from apps.commons.queryset import MultipleIdsQuerySet from apps.commons.utils import get_write_permissions_from_subscopes from services.translator.mixins import HasAutoTranslatedFields @@ -46,7 +49,7 @@ def uuid_generator() -> str: return shortuuid.ShortUUID().random(length=8) -class SoftDeleteManager(models.Manager): +class SoftDeleteManager(models.manager.BaseManager.from_queryset(MultipleIdsQuerySet)): """Exclude by default soft-deleted Projects.""" def get_queryset(self): @@ -65,7 +68,9 @@ def deleted_projects(self): class Project( + HasEmbedding, HasMultipleIDs, + HasRelatedModules, HasAutoTranslatedFields, HasPermissionsSetup, ProjectRelated, @@ -245,7 +250,7 @@ def content_type(self) -> ContentType: return ContentType.objects.get_for_model(Project) def __init__(self, *args, **kwargs): - super(Project, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._original_description = self.description self._related_organizations = None @@ -285,7 +290,7 @@ def delete(self, using=None, keep_parents=False): def hard_delete(self): """Hard-delete the project.""" self.groups.all().delete() - super(Project, self).delete() + super().delete() def restore(self): """Restore a soft-deleted project.""" @@ -922,7 +927,7 @@ def get_related_organizations(self) -> list["Organization"]: class ProjectMessage(HasAutoTranslatedFields, ProjectRelated, HasOwner, models.Model): """ - A message in a project. + A message in a project (private-exchange) Attributes ---------- diff --git a/apps/projects/permissions.py b/apps/projects/permissions.py index ec5a810b..e002e91c 100644 --- a/apps/projects/permissions.py +++ b/apps/projects/permissions.py @@ -99,24 +99,18 @@ def user_can_modify_locked_project( ) def has_permission(self, request: Request, view: GenericViewSet) -> bool: - project = self.get_related_project(request, view) - if ( - project - and view.action == "create" - and project.is_locked - and not self.user_can_modify_locked_project(project, request.user) - ): - raise LockedProjectError - return True + return self.has_object_permission(request, view, None) def has_object_permission( self, request: Request, view: GenericViewSet, obj: ProjectRelated ) -> bool: project = self.get_related_project(request, view, obj) + if ( project and view.action in [ + "create", "update", "partial_update", "destroy", diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 5364ad6d..223c50de 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -6,13 +6,13 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator -from apps.accounts.models import AnonymousUser, PeopleGroup, ProjectUser +from apps.accounts.models import PeopleGroup, ProjectUser from apps.accounts.serializers import ( PeopleGroupLightSerializer, UserLighterSerializer, ) -from apps.announcements.serializers import AnnouncementSerializer from apps.commons.fields import ( HiddenPrimaryKeyRelatedField, RecursiveField, @@ -27,13 +27,10 @@ StringsImagesSerializer, ) from apps.feedbacks.models import Comment, Follow -from apps.feedbacks.serializers import CommentSerializer, ReviewSerializer +from apps.feedbacks.serializers import CommentSerializer from apps.files.models import Image -from apps.files.serializers import ( - AttachmentFileSerializer, - AttachmentLinkSerializer, - ImageSerializer, -) +from apps.files.serializers import ImageSerializer +from apps.modules.serializers import ModulesSerializers from apps.notifications.tasks import notify_new_project, notify_project_changes from apps.organizations.models import Organization, ProjectCategory, Template from apps.organizations.serializers import ( @@ -42,7 +39,7 @@ ProjectTemplateSerializer, ) from apps.skills.models import Tag -from apps.skills.serializers import TagRelatedField, TagSerializer +from apps.skills.serializers import TagRelatedField from services.translator.serializers import auto_translated from .exceptions import ( @@ -120,7 +117,7 @@ def update(self, instance, validated_data): created_at=self.initial_data["created_at"] ) instance.refresh_from_db() - return super(BlogEntrySerializer, self).update(instance, validated_data) + return super().update(instance, validated_data) def get_related_organizations(self) -> list[Organization]: """Retrieve the related organizations""" @@ -182,64 +179,208 @@ def get_related_project(self) -> Project | None: @auto_translated -class LocationProjectSerializer(serializers.ModelSerializer): - header_image = ImageSerializer(read_only=True) +class ProjectSerializer( + ModulesSerializers, + StringsImagesSerializer, + OrganizationRelatedSerializer, + serializers.ModelSerializer, +): + string_images_fields: list[str] = ["description"] + string_images_forbid_fields: list[str] = ["title", "purpose"] + string_images_upload_to: str = "project/images/" + string_images_view: str = "Project-images-detail" + string_images_process_template: bool = True - class Meta: - model = Project - fields = ["id", "slug", "title", "purpose", "header_image"] + tags = TagRelatedField(many=True, required=False) + # read_only + header_image = ImageSerializer(read_only=True) + categories = ProjectCategoryLightSerializer(many=True, read_only=True) + organizations = OrganizationSerializer(many=True, read_only=True) -@auto_translated -class LocationSerializer(ProjectRelatedSerializer, BaseLocationSerializer): - string_images_forbid_fields: list[str] = ["title", "description"] + template = ProjectTemplateSerializer(read_only=True) + views = serializers.SerializerMethodField() + is_followed = serializers.SerializerMethodField(read_only=True) - project = LocationProjectSerializer(read_only=True) - project_id = serializers.PrimaryKeyRelatedField( - many=False, + # write_only + header_image_id = serializers.PrimaryKeyRelatedField( write_only=True, - queryset=Project.objects.all(), - source="project", + queryset=Image.objects.all(), + source="header_image", + required=False, + ) + project_categories_ids = serializers.PrimaryKeyRelatedField( + many=True, + write_only=True, + queryset=ProjectCategory.objects.all(), + source="categories", + required=False, + ) + organizations_codes = serializers.SlugRelatedField( + write_only=True, + slug_field="code", + source="organizations", + queryset=Organization.objects.all(), + many=True, + required=True, + ) + images_ids = serializers.PrimaryKeyRelatedField( + many=True, + write_only=True, + queryset=Image.objects.all(), + source="images", + required=False, + ) + template_id = serializers.PrimaryKeyRelatedField( + required=False, + write_only=True, + queryset=Template.objects.all(), + source="template", ) class Meta: - model = Location - fields = [ + model = Project + read_only_fields = ["is_locked", "slug"] + fields = read_only_fields + [ "id", "title", "description", - "lat", - "lng", - "type", - "project", + "is_shareable", + "purpose", + "language", + "publication_status", + "life_status", + "sdgs", + "created_at", + "updated_at", + "deleted_at", + "tags", + # read only + "header_image", + "categories", + # "last_comment", + "organizations", + "views", + "template", + "is_followed", # write_only - "project_id", + "project_categories_ids", + "header_image_id", + "template_id", + "organizations_codes", + "images_ids", + # "team", + "modules", ] - def get_related_project(self) -> Project | None: - """Retrieve the related projects""" - if "project" in self.validated_data: - return self.validated_data["project"] - return None + def get_is_followed(self, project: Project) -> dict[str, Any]: + if "request" in self.context: + user = self.context["request"].user + if not user.is_anonymous: + follow = Follow.objects.filter(follower=user, project=project) + user_follow = follow.first() + if user_follow: + return {"is_followed": True, "follow_id": user_follow.id} + return {"is_followed": False, "follow_id": None} + def get_string_images_kwargs( + self, instance: Project, field_name: str, *args: Any, **kwargs: Any + ) -> dict[str, Any]: + return {"project_id": instance.id} -@auto_translated -class ProjectSuperLightSerializer(serializers.ModelSerializer): - class Meta: - model = Project - fields = ["id", "slug", "title"] + def get_related_organizations(self) -> list[Organization]: + """Retrieve the related organizations""" + if "organizations" in self.validated_data: + return self.validated_data["organizations"] + return [] + + def create(self, validated_data): + team = validated_data.pop("team", {}) + project = super().create(validated_data) + ProjectAddTeamMembersSerializer().create({"project": project, **team}) + notify_new_project.delay(project.pk, self.context["request"].user.pk) + return project + + def update(self, instance, validated_data): + validated_data.pop("team", {}) + changes = compute_project_changes(instance, validated_data) + notify_project_changes.delay( + instance.pk, changes, self.context["request"].user.pk + ) + return super().update(instance, validated_data) + + def validate_organizations_codes(self, value: list[Organization]): + if len(value) < 1: + raise ProjectWithNoOrganizationError + request = self.context.get("request") + if request: + organizations_to_add = ( + [o for o in value if o not in self.instance.organizations.all()] + if self.instance + else value + ) + if not all( + request.user.has_perm("organizations.add_project", organization) + for organization in organizations_to_add + ): + raise AddProjectToOrganizationPermissionError + return value + + def validate_publication_status(self, value: str): + request = self.context["request"] + user = request.user + if ( + not self.instance + or self.instance.publication_status == value + or not any( + category.only_reviewer_can_publish + for category in self.instance.categories.all() + ) + or user.is_superuser + or any( + (o.admins.all() | o.facilitators.all()).contains(user) + for o in self.instance.organizations.all() + ) + or self.instance.reviewers.contains(user) + or self.instance.reviewer_groups_users.contains(user) + ): + return value + raise OnlyReviewerCanChangeStatusError + + # This is a fix to prevent bugs from hocus pocus + # TODO: Remove this validation when history is implemented in the frontend + def validate_description(self, value: str): + if not self.instance: + return value + empty_descriptions = ["
", ""] + if ( + self.instance.description not in empty_descriptions + and value in empty_descriptions + ): + raise EmptyProjectDescriptionError + return value + + def validate_categories(self, value: list[ProjectCategory]): + organizations_codes = self.initial_data.get("organizations_codes", []) + if self.instance and not organizations_codes: + organizations_codes = self.instance.organizations.all().values_list( + "code", flat=True + ) + if not all( + category.organization.code in organizations_codes for category in value + ): + raise ProjectCategoryOrganizationError + return value + + get_views = get_views_from_serializer @auto_translated -class ProjectLightSerializer(serializers.ModelSerializer): - categories = ProjectCategoryLightSerializer(many=True, read_only=True) - header_image = ImageSerializer(read_only=True) - is_followed = serializers.SerializerMethodField(read_only=True) +class ProjectLightSerializer(ProjectSerializer): is_featured = serializers.BooleanField(read_only=True, required=False) is_group_project = serializers.BooleanField(read_only=True, required=False) - tags = TagSerializer(many=True, read_only=True) - class Meta: + class Meta(ProjectSerializer.Meta): model = Project fields = [ "id", @@ -260,15 +401,44 @@ class Meta: "tags", ] - def get_is_followed(self, project: Project) -> dict[str, Any]: - if "request" in self.context: - user = self.context["request"].user - if not user.is_anonymous: - follow = Follow.objects.filter(follower=user, project=project) - user_follow = follow.first() - if user_follow: - return {"is_followed": True, "follow_id": user_follow.id} - return {"is_followed": False, "follow_id": None} + +@auto_translated +class ProjectSuperLightSerializer(ProjectLightSerializer): + class Meta(ProjectLightSerializer.Meta): + fields = ["id", "slug", "title", "purpose", "header_image"] + + +@auto_translated +class LocationSerializer(ProjectRelatedSerializer, BaseLocationSerializer): + string_images_forbid_fields: list[str] = ["title", "description"] + + project = ProjectSuperLightSerializer(read_only=True) + project_id = serializers.PrimaryKeyRelatedField( + many=False, + write_only=True, + queryset=Project.objects.all(), + source="project", + ) + + class Meta: + model = Location + fields = [ + "id", + "title", + "description", + "lat", + "lng", + "type", + "project", + # write_only + "project_id", + ] + + def get_related_project(self) -> Project | None: + """Retrieve the related projects""" + if "project" in self.validated_data: + return self.validated_data["project"] + return None class ProjectRemoveLinkedProjectSerializer(serializers.ModelSerializer): @@ -299,6 +469,15 @@ class Meta: model = LinkedProject fields = ["id", "project_id", "target_id", "project"] + def run_validators(self, value): + # ignore unique_together + self.validators = [ + validator + for validator in self.validators + if not isinstance(validator, UniqueTogetherValidator) + ] + return super().run_validators(value) + def validate(self, data): project = None target = None @@ -306,31 +485,36 @@ def validate(self, data): project = data["project"] elif self.instance: project = self.instance.project + if "target" in data: target = data["target"] elif self.instance: target = self.instance.target + if project and target and project == target: raise LinkProjectToSelfError(target.title) - return data - - -class ProjectAddLinkedProjectSerializer(serializers.Serializer): - """Used to link projects to another one.""" - projects = LinkedProjectSerializer(many=True) - - def update(self, instance, validated_data): - pass - - def create(self, validated_data): - pass + return data + def create(self, validate_data: dict) -> LinkedProject: + linked, _ = LinkedProject.objects.get_or_create( + project=validate_data["project"], + target=validate_data["target"], + ) + return linked -class ProjectIdSerializer(serializers.Serializer): - """Used to retrieve a project from their `id`.""" + def update(self, instance, validate_data: dict) -> LinkedProject: + linked = LinkedProject.objects.filter( + project=validate_data.get("project", instance.project), + target=validate_data.get("target", instance.target), + ).first() + # already linked exists so delete it + if linked: + instance.delete() + else: + return super().update(instance, validate_data) - project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all()) + return linked class UserLighterSerializerKeycloakRelatedField( @@ -353,6 +537,23 @@ def to_internal_value(self, data): return serializers.PrimaryKeyRelatedField.to_internal_value(self, data) +class ProjectTeamMembersSerializer(UserLighterSerializer): + role = serializers.CharField() + + class Meta(UserLighterSerializer.Meta): + fields = UserLighterSerializer.Meta.fields + ("role",) + + +class ProjectGroupSerializer(PeopleGroupLightSerializer): + role = serializers.SerializerMethodField() + + class Meta(PeopleGroupLightSerializer.Meta): + fields = PeopleGroupLightSerializer.Meta.fields + ("role",) + + def get_role(self, instance: PeopleGroup): + return instance.role + + class ProjectAddTeamMembersSerializer(serializers.Serializer): project = HiddenPrimaryKeyRelatedField( required=False, write_only=True, queryset=Project.objects.all() @@ -510,233 +711,6 @@ def create(self, validated_data): } -@auto_translated -class ProjectSerializer( - StringsImagesSerializer, - OrganizationRelatedSerializer, - serializers.ModelSerializer, -): - string_images_fields: list[str] = ["description"] - string_images_forbid_fields: list[str] = ["title", "purpose"] - string_images_upload_to: str = "project/images/" - string_images_view: str = "Project-images-detail" - string_images_process_template: bool = True - - team = ProjectAddTeamMembersSerializer(required=False, source="*") - tags = TagRelatedField(many=True, required=False) - - # read_only - header_image = ImageSerializer(read_only=True) - categories = ProjectCategoryLightSerializer(many=True, read_only=True) - last_comment = serializers.SerializerMethodField(read_only=True) - organizations = OrganizationSerializer(many=True, read_only=True) - goals = GoalSerializer(many=True, read_only=True) - reviews = ReviewSerializer(many=True, read_only=True) - locations = LocationSerializer(many=True, read_only=True) - announcements = AnnouncementSerializer(many=True, read_only=True) - links = AttachmentLinkSerializer(many=True, read_only=True) - files = AttachmentFileSerializer(many=True, read_only=True) - images = ImageSerializer(many=True, read_only=True) - blog_entries = BlogEntrySerializer(many=True, read_only=True) - linked_projects = serializers.SerializerMethodField(read_only=True) - template = ProjectTemplateSerializer(read_only=True) - views = serializers.SerializerMethodField() - is_followed = serializers.SerializerMethodField(read_only=True) - - # write_only - header_image_id = serializers.PrimaryKeyRelatedField( - write_only=True, - queryset=Image.objects.all(), - source="header_image", - required=False, - ) - project_categories_ids = serializers.PrimaryKeyRelatedField( - many=True, - write_only=True, - queryset=ProjectCategory.objects.all(), - source="categories", - required=False, - ) - organizations_codes = serializers.SlugRelatedField( - write_only=True, - slug_field="code", - source="organizations", - queryset=Organization.objects.all(), - many=True, - required=True, - ) - images_ids = serializers.PrimaryKeyRelatedField( - many=True, - write_only=True, - queryset=Image.objects.all(), - source="images", - required=False, - ) - template_id = serializers.PrimaryKeyRelatedField( - required=False, - write_only=True, - queryset=Template.objects.all(), - source="template", - ) - - class Meta: - model = Project - read_only_fields = ["is_locked", "slug"] - fields = read_only_fields + [ - "id", - "title", - "description", - "is_shareable", - "purpose", - "language", - "publication_status", - "life_status", - "sdgs", - "created_at", - "updated_at", - "deleted_at", - "tags", - # read only - "header_image", - "categories", - "last_comment", - "organizations", - "goals", - "reviews", - "locations", - "announcements", - "links", - "files", - "images", - "blog_entries", - "linked_projects", - "views", - "template", - "is_followed", - # write_only - "project_categories_ids", - "header_image_id", - "template_id", - "organizations_codes", - "images_ids", - "team", - ] - - @staticmethod - def get_last_comment(project: Project) -> dict | None: - last_comment = ( - project.comments.filter(reply_on=None).order_by("-created_at").first() - ) - return CommentSerializer(last_comment).data if last_comment else None - - def get_linked_projects(self, project: Project) -> dict[str, Any]: - queryset = LinkedProject.objects.filter(target=project) - user = getattr(self.context.get("request"), "user", AnonymousUser()) - queryset = user.get_project_related_queryset(queryset) - return LinkedProjectSerializer(queryset, many=True).data - - def get_is_followed(self, project: Project) -> dict[str, Any]: - if "request" in self.context: - user = self.context["request"].user - if not user.is_anonymous: - follow = Follow.objects.filter(follower=user, project=project) - user_follow = follow.first() - if user_follow: - return {"is_followed": True, "follow_id": user_follow.id} - return {"is_followed": False, "follow_id": None} - - def get_string_images_kwargs( - self, instance: Project, field_name: str, *args: Any, **kwargs: Any - ) -> dict[str, Any]: - return {"project_id": instance.id} - - def get_related_organizations(self) -> list[Organization]: - """Retrieve the related organizations""" - if "organizations" in self.validated_data: - return self.validated_data["organizations"] - return [] - - def create(self, validated_data): - team = validated_data.pop("team", {}) - project = super(ProjectSerializer, self).create(validated_data) - ProjectAddTeamMembersSerializer().create({"project": project, **team}) - notify_new_project.delay(project.pk, self.context["request"].user.pk) - return project - - def update(self, instance, validated_data): - validated_data.pop("team", {}) - changes = compute_project_changes(instance, validated_data) - notify_project_changes.delay( - instance.pk, changes, self.context["request"].user.pk - ) - return super(ProjectSerializer, self).update(instance, validated_data) - - def validate_organizations_codes(self, value: list[Organization]): - if len(value) < 1: - raise ProjectWithNoOrganizationError - request = self.context.get("request") - if request: - organizations_to_add = ( - [o for o in value if o not in self.instance.organizations.all()] - if self.instance - else value - ) - if not all( - request.user.has_perm("organizations.add_project", organization) - for organization in organizations_to_add - ): - raise AddProjectToOrganizationPermissionError - return value - - def validate_publication_status(self, value: str): - request = self.context["request"] - user = request.user - if ( - not self.instance - or self.instance.publication_status == value - or not any( - category.only_reviewer_can_publish - for category in self.instance.categories.all() - ) - or user.is_superuser - or any( - (o.admins.all() | o.facilitators.all()).contains(user) - for o in self.instance.organizations.all() - ) - or self.instance.reviewers.contains(user) - or self.instance.reviewer_groups_users.contains(user) - ): - return value - raise OnlyReviewerCanChangeStatusError - - # This is a fix to prevent bugs from hocus pocus - # TODO: Remove this validation when history is implemented in the frontend - def validate_description(self, value: str): - if not self.instance: - return value - empty_descriptions = ["", ""] - if ( - self.instance.description not in empty_descriptions - and value in empty_descriptions - ): - raise EmptyProjectDescriptionError - return value - - def validate_categories(self, value: list[ProjectCategory]): - organizations_codes = self.initial_data.get("organizations_codes", []) - if self.instance and not organizations_codes: - organizations_codes = self.instance.organizations.all().values_list( - "code", flat=True - ) - if not all( - category.organization.code in organizations_codes for category in value - ): - raise ProjectCategoryOrganizationError - return value - - get_views = get_views_from_serializer - - class ProjectVersionSerializer(serializers.ModelSerializer): id = serializers.SerializerMethodField(read_only=True) project_id = serializers.SerializerMethodField(read_only=True) diff --git a/apps/projects/tests/views/test_blog_entry.py b/apps/projects/tests/views/test_blog_entry.py index 9c792f05..199be156 100644 --- a/apps/projects/tests/views/test_blog_entry.py +++ b/apps/projects/tests/views/test_blog_entry.py @@ -100,13 +100,17 @@ def test_retrieve_blog_entry(self, role, retrieved_blog_entries): user = self.get_parameterized_test_user(role, instances=[project]) self.client.force_authenticate(user) response = self.client.get(reverse("BlogEntry-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] + if publication_status in retrieved_blog_entries: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], blog_entry.id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) class UpdateBlogEntryTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_blog_entry_images.py b/apps/projects/tests/views/test_blog_entry_images.py index 43aec5b1..f577d3ff 100644 --- a/apps/projects/tests/views/test_blog_entry_images.py +++ b/apps/projects/tests/views/test_blog_entry_images.py @@ -46,7 +46,6 @@ def setUpTestData(cls): (TestRoles.ANONYMOUS, ("public",)), (TestRoles.DEFAULT, ("public",)), (TestRoles.SUPERADMIN, ("public", "org", "private")), - (TestRoles.OWNER, ("public", "org", "private")), (TestRoles.ORG_ADMIN, ("public", "org", "private")), (TestRoles.ORG_FACILITATOR, ("public", "org", "private")), (TestRoles.ORG_USER, ("public", "org")), @@ -70,7 +69,10 @@ def test_retrieve_blog_entry_image(self, role, retrieved_images): if publication_status in retrieved_images: self.assertEqual(response.status_code, status.HTTP_302_FOUND) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) class CreateBlogEntryImageTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_goal.py b/apps/projects/tests/views/test_goal.py index 99435887..3592ba54 100644 --- a/apps/projects/tests/views/test_goal.py +++ b/apps/projects/tests/views/test_goal.py @@ -164,10 +164,14 @@ def test_list_goals(self, role, retrieved_goals): self.client.force_authenticate(user) for publication_status, project in self.projects.items(): response = self.client.get(reverse("Goal-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] + if publication_status in retrieved_goals: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], self.goals[publication_status].id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) diff --git a/apps/projects/tests/views/test_linked_project.py b/apps/projects/tests/views/test_linked_project.py index 04b48f46..b79e6c71 100644 --- a/apps/projects/tests/views/test_linked_project.py +++ b/apps/projects/tests/views/test_linked_project.py @@ -66,18 +66,16 @@ def test_create_many_linked_projects(self, role, expected_code): project = ProjectFactory(organizations=[self.organization]) user = self.get_parameterized_test_user(role, instances=[project]) self.client.force_authenticate(user) - payload = { - "projects": [ - { - "project_id": self.linked_project_1.id, - "target_id": project.id, - }, - { - "project_id": self.linked_project_2.id, - "target_id": project.id, - }, - ] - } + payload = [ + { + "project_id": self.linked_project_1.id, + "target_id": project.id, + }, + { + "project_id": self.linked_project_2.id, + "target_id": project.id, + }, + ] response = self.client.post( reverse("LinkedProjects-add-many", args=(project.id,)), data=payload ) @@ -85,7 +83,7 @@ def test_create_many_linked_projects(self, role, expected_code): if expected_code == status.HTTP_200_OK: content = response.json() self.assertSetEqual( - {p["project"]["id"] for p in content["linked_projects"]}, + {p["project"]["id"] for p in content}, {self.linked_project_1.id, self.linked_project_2.id}, ) @@ -182,21 +180,22 @@ def test_link_many_projects_to_itself(self): project = ProjectFactory(organizations=[self.organization]) project_2 = ProjectFactory(organizations=[self.organization]) self.client.force_authenticate(user) - payload = { - "projects": [ - {"project_id": project_2.id, "target_id": project.id}, - {"project_id": project.id, "target_id": project.id}, - ] - } + payload = [ + {"project_id": project_2.id, "target_id": project.id}, + {"project_id": project.id, "target_id": project.id}, + ] response = self.client.post( reverse("LinkedProjects-add-many", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertApiValidationError( response, - { - "project_id": [ - f"The project '{project.title}' can't be linked to itself" - ] - }, + [ + {}, + { + "project_id": [ + f"The project '{project.title}' can't be linked to itself" + ] + }, + ], ) diff --git a/apps/projects/tests/views/test_location.py b/apps/projects/tests/views/test_location.py index ae471b09..84f0cd43 100644 --- a/apps/projects/tests/views/test_location.py +++ b/apps/projects/tests/views/test_location.py @@ -106,13 +106,16 @@ def test_retrieve_location(self, role, retrieved_locations): user = self.get_parameterized_test_user(role, instances=[project]) self.client.force_authenticate(user) response = self.client.get(reverse("Location-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json() if publication_status in retrieved_locations: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], location.id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) class UpdateLocationTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_locked_project.py b/apps/projects/tests/views/test_locked_project.py index b26ce5d3..30035c9a 100644 --- a/apps/projects/tests/views/test_locked_project.py +++ b/apps/projects/tests/views/test_locked_project.py @@ -104,7 +104,7 @@ def test_add_member_to_locked_project(self, role, expected_code): self.client.force_authenticate(user) payload = {"members": []} response = self.client.post( - reverse("Project-add-member", args=(self.project.id,)), data=payload + reverse("Project-member-add-member", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_403_FORBIDDEN: @@ -126,7 +126,7 @@ def test_remove_member_from_locked_project(self, role, expected_code): self.client.force_authenticate(user) payload = {"users": []} response = self.client.post( - reverse("Project-remove-member", args=(self.project.id,)), + reverse("Project-member-remove-member", args=(self.project.id,)), data=payload, ) self.assertEqual(response.status_code, expected_code) diff --git a/apps/projects/tests/views/test_project.py b/apps/projects/tests/views/test_project.py index 52fc2978..3bfd6309 100644 --- a/apps/projects/tests/views/test_project.py +++ b/apps/projects/tests/views/test_project.py @@ -1,6 +1,8 @@ import datetime +import json import random +from django.core import serializers from django.urls import reverse from django.utils import timezone from django.utils.timezone import make_aware @@ -29,6 +31,11 @@ from apps.projects.models import Project from apps.skills.factories import TagFactory + +def modeljson(model): + return json.loads(serializers.serialize("json", [model]))[0]["fields"] + + faker = Faker() @@ -84,20 +91,13 @@ def test_create_project(self, role, expected_code): "project_categories_ids": [self.category.id], "template_id": self.template.id, "tags": [t.id for t in self.tags], - "images_ids": [], - "team": { - "members": [m.id for m in self.members], - "reviewers": [r.id for r in self.reviewers], - "owners": [o.id for o in self.owners], - "member_groups": [pg.id for pg in self.member_groups], - "reviewer_groups": [pg.id for pg in self.reviewer_groups], - "owner_groups": [pg.id for pg in self.owner_groups], - }, } response = self.client.post(reverse("Project-list"), data=payload) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_201_CREATED: + content = response.json() + self.assertEqual(content["title"], payload["title"]) self.assertEqual(content["description"], payload["description"]) self.assertEqual(content["is_shareable"], payload["is_shareable"]) @@ -120,30 +120,6 @@ def test_create_project(self, role, expected_code): self.assertSetEqual( {t["id"] for t in content["tags"]}, set(payload["tags"]) ) - self.assertSetEqual( - {u["id"] for u in content["team"]["members"]}, - set(payload["team"]["members"]), - ) - self.assertSetEqual( - {u["id"] for u in content["team"]["reviewers"]}, - set(payload["team"]["reviewers"]), - ) - self.assertSetEqual( - {u["id"] for u in content["team"]["owners"]}, - {user.id, *payload["team"]["owners"]}, - ) - self.assertSetEqual( - {u["id"] for u in content["team"]["member_groups"]}, - set(payload["team"]["member_groups"]), - ) - self.assertSetEqual( - {u["id"] for u in content["team"]["reviewer_groups"]}, - set(payload["team"]["reviewer_groups"]), - ) - self.assertSetEqual( - {u["id"] for u in content["team"]["owner_groups"]}, - set(payload["team"]["owner_groups"]), - ) class UpdateProjectTestCase(JwtAPITestCase): @@ -348,7 +324,7 @@ def test_add_project_member(self, role, expected_code): "reviewer_groups": [pg.id for pg in self.reviewer_groups], } response = self.client.post( - reverse("Project-add-member", args=(project.id,)), data=payload + reverse("Project-member-add-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_204_NO_CONTENT: @@ -400,7 +376,7 @@ def test_remove_project_member(self, role, expected_code): ], } response = self.client.post( - reverse("Project-remove-member", args=(project.id,)), data=payload + reverse("Project-member-remove-member", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_204_NO_CONTENT: @@ -468,7 +444,9 @@ def setUpTestData(cls) -> None: ) blog_entries[0].save() - def check_duplicated_project(self, duplicated_project: dict, initial_project: dict): + def check_duplicated_project( + self, duplicated_project: Project, initial_project: Project + ): fields = [ "is_locked", "title", @@ -488,94 +466,93 @@ def check_duplicated_project(self, duplicated_project: dict, initial_project: di ] list_fields = ["sdgs"] self.assertEqual( - duplicated_project["publication_status"], + duplicated_project.publication_status, Project.PublicationStatus.PRIVATE, ) - self.assertNotEqual( - duplicated_project["created_at"], initial_project["created_at"] - ) - self.assertNotEqual( - duplicated_project["updated_at"], initial_project["updated_at"] - ) + self.assertNotEqual(duplicated_project.created_at, initial_project.created_at) + self.assertNotEqual(duplicated_project.updated_at, initial_project.updated_at) for field in fields: - self.assertEqual(duplicated_project[field], initial_project[field]) + self.assertEqual( + getattr(duplicated_project, field), getattr(initial_project, field) + ) for field in list_fields: self.assertSetEqual( - set(duplicated_project[field]), set(initial_project[field]) + set(getattr(duplicated_project, field)), + set(getattr(initial_project, field)), ) for field in many_to_many_fields: self.assertSetEqual( - {item["id"] for item in duplicated_project[field]}, - {item["id"] for item in initial_project[field]}, + {item.id for item in getattr(duplicated_project, field).all()}, + {item.id for item in getattr(initial_project, field).all()}, ) for related_field in related_fields: self.assertEqual( - len(duplicated_project[related_field]), - len(initial_project[related_field]), + getattr(duplicated_project, related_field).count(), + getattr(initial_project, related_field).count(), ) duplicated_field = [ { key: value - for key, value in item.items() + for key, value in modeljson(item).items() if key not in ["id", "project", "created_at", "updated_at", "file"] } - for item in duplicated_project[related_field] + for item in getattr(duplicated_project, related_field).all() ] initial_field = [ { key: value - for key, value in item.items() + for key, value in modeljson(item).items() if key not in ["id", "project", "created_at", "updated_at", "file"] } - for item in initial_project[related_field] + for item in getattr(initial_project, related_field).all() ] self.assertEqual(len(duplicated_field), len(initial_field)) for item in duplicated_field: self.assertIn(item, initial_field) self.assertEqual( - len(duplicated_project["images"]), len(initial_project["images"]) + duplicated_project.images.count(), initial_project.images.count() ) - for duplicated_image in duplicated_project["images"]: - self.assertNotIn( - duplicated_image["id"], - [i["id"] for i in initial_project["images"]], - ) + initial_images_ids = (initial_project.images.values_list("id", flat=True),) + for duplicated_image in duplicated_project.images.all(): + self.assertNotIn(duplicated_image.id, initial_images_ids) self.assertIn( - f'