From 169c532b60e799c5ded81bcb1f7fd20be95fadec Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 17 Apr 2026 16:15:25 +0200 Subject: [PATCH 01/24] refactor: projects pages --- apps/modules/__init__.py | 3 ++- apps/modules/base.py | 13 ++++++--- apps/modules/project.py | 52 ++++++++++++++++++++++++++++++++++++ apps/projects/models.py | 10 ++++--- apps/projects/serializers.py | 11 +++++--- 5 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 apps/modules/project.py 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..6e42b612 100644 --- a/apps/modules/base.py +++ b/apps/modules/base.py @@ -1,10 +1,13 @@ import inspect +from ast import Call from collections.abc import Callable from functools import cache 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 +20,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 +47,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 +85,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/project.py b/apps/modules/project.py new file mode 100644 index 00000000..b949c1e7 --- /dev/null +++ b/apps/modules/project.py @@ -0,0 +1,52 @@ +from django.db.models import Case, Prefetch, Q, QuerySet, Value, When + +from apps.accounts.models import PeopleGroup, ProjectUser +from apps.announcements.models import Announcement +from apps.feedbacks.models import Comment +from apps.files.models import AttachmentFile, AttachmentLink +from apps.modules.base import AbstractModules, register_module +from apps.projects.models import BlogEntry, Goal, Location, Project + + +@register_module(Project) +class ProjectModules(AbstractModules): + instance: Project + + def members(self) -> QuerySet[ProjectUser]: + return self.instance.get_all_members().filter( + pk__in=self.user.get_user_queryset() + ) + + def groups(self) -> QuerySet[PeopleGroup]: + return self.instance.get_all_groups().filter( + pk__in=self.user.get_people_group_queryset() + ) + + 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() diff --git a/apps/projects/models.py b/apps/projects/models.py index dde29d26..e6ef7d2b 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -14,20 +14,22 @@ from django.db.models import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords, HistoricForeignKey from apps.analytics.models import Stat 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.utils import get_write_permissions_from_subscopes -from services.translator.mixins import HasAutoTranslatedFields from .exceptions import WrongProjectOrganizationError @@ -65,7 +67,9 @@ def deleted_projects(self): class Project( + HasEmbedding, HasMultipleIDs, + HasRelatedModules, HasAutoTranslatedFields, HasPermissionsSetup, ProjectRelated, @@ -245,7 +249,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 +289,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.""" diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 5364ad6d..e6be4f20 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -6,6 +6,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import serializers +from services.translator.serializers import auto_translated from apps.accounts.models import AnonymousUser, PeopleGroup, ProjectUser from apps.accounts.serializers import ( @@ -34,6 +35,7 @@ AttachmentLinkSerializer, 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 ( @@ -43,7 +45,6 @@ ) from apps.skills.models import Tag from apps.skills.serializers import TagRelatedField, TagSerializer -from services.translator.serializers import auto_translated from .exceptions import ( AddProjectToOrganizationPermissionError, @@ -120,7 +121,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""" @@ -512,6 +513,7 @@ def create(self, validated_data): @auto_translated class ProjectSerializer( + ModulesSerializers, StringsImagesSerializer, OrganizationRelatedSerializer, serializers.ModelSerializer, @@ -620,6 +622,7 @@ class Meta: "organizations_codes", "images_ids", "team", + "modules", ] @staticmethod @@ -658,7 +661,7 @@ def get_related_organizations(self) -> list[Organization]: def create(self, validated_data): team = validated_data.pop("team", {}) - project = super(ProjectSerializer, self).create(validated_data) + project = super().create(validated_data) ProjectAddTeamMembersSerializer().create({"project": project, **team}) notify_new_project.delay(project.pk, self.context["request"].user.pk) return project @@ -669,7 +672,7 @@ def update(self, instance, validated_data): notify_project_changes.delay( instance.pk, changes, self.context["request"].user.pk ) - return super(ProjectSerializer, self).update(instance, validated_data) + return super().update(instance, validated_data) def validate_organizations_codes(self, value: list[Organization]): if len(value) < 1: From 5380e983c87b28d61aa155f873fa883dcaffacf4 Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 20 Apr 2026 17:14:47 +0200 Subject: [PATCH 02/24] change serializers --- apps/modules/project.py | 5 +- apps/projects/serializers.py | 502 ++++++++++++++++------------------- apps/projects/views.py | 31 ++- 3 files changed, 255 insertions(+), 283 deletions(-) diff --git a/apps/modules/project.py b/apps/modules/project.py index b949c1e7..fd020fef 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -2,7 +2,7 @@ from apps.accounts.models import PeopleGroup, ProjectUser from apps.announcements.models import Announcement -from apps.feedbacks.models import Comment +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 @@ -50,3 +50,6 @@ def links(self) -> QuerySet[AttachmentLink]: def announcements(self) -> QuerySet[Announcement]: return self.instance.announcements.all() + + def reviews(self) -> QuerySet[Review]: + return self.instance.reviews.all() diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index e6be4f20..cf16fd46 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -183,64 +183,218 @@ 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"] + # team = ProjectAddTeamMembersSerializer(required=False, source="*", write_only=True) + tags = TagRelatedField(many=True, required=False) + # read_only + header_image = ImageSerializer(read_only=True) + categories = ProjectCategoryLightSerializer(many=True, read_only=True) + # last_comment = serializers.SerializerMsethodField(read_only=True) + organizations = OrganizationSerializer(many=True, read_only=True) -@auto_translated -class LocationSerializer(ProjectRelatedSerializer, BaseLocationSerializer): - string_images_forbid_fields: list[str] = ["title", "description"] + # images = ImageSerializer(many=True, read_only=True) + 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 + # @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_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(serializers.ModelSerializer): - class Meta: - model = Project - fields = ["id", "slug", "title"] + 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().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", @@ -261,15 +415,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): @@ -511,235 +694,6 @@ def create(self, validated_data): } -@auto_translated -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 - - 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", - "modules", - ] - - @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().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 - - class ProjectVersionSerializer(serializers.ModelSerializer): id = serializers.SerializerMethodField(read_only=True) project_id = serializers.SerializerMethodField(read_only=True) diff --git a/apps/projects/views.py b/apps/projects/views.py index 2ad024e7..d5a10f2c 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -18,6 +18,7 @@ IsAuthenticatedOrReadOnly, ) from rest_framework.response import Response +from services.mistral.models import ProjectEmbedding from simple_history.utils import update_change_reason from apps.accounts.models import PeopleGroupLocation @@ -55,7 +56,6 @@ LinkedProjectPermissionDeniedError, OrganizationsParameterMissing, ) -from services.mistral.models import ProjectEmbedding from .filters import ProjectFilter from .models import ( @@ -172,7 +172,7 @@ def perform_update(self, serializer: ProjectSerializer): def perform_destroy(self, instance): if settings.ENABLE_CACHE and instance.announcements.exists(): cache.delete_many(cache.keys("announcements_list_cache*")) - super(ProjectViewSet, self).perform_destroy(instance) + super().perform_destroy(instance) @extend_schema( parameters=[ @@ -186,7 +186,7 @@ def perform_destroy(self, instance): ] ) def list(self, request, *args, **kwargs): - return super(ProjectViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) @extend_schema( parameters=[ @@ -218,6 +218,22 @@ def duplicate(self, request, *args, **kwargs): status=status.HTTP_201_CREATED, ) + @action( + detail=False, + methods=["GET", "LIST"], + url_path="member", + permission_classes=[ + IsAuthenticated, + ProjectIsNotLocked, + ], + ) + def members(self, request, *ar, **kwargs): + project = self.get_object() + modules_manager = project.get_related_module() + modules = modules_manager(project, request.user) + + return modules.members() + @extend_schema(request=ProjectAddTeamMembersSerializer, responses=ProjectSerializer) @action( detail=True, @@ -225,7 +241,6 @@ def duplicate(self, request, *args, **kwargs): url_path="member/add", permission_classes=[ IsAuthenticated, - ProjectIsNotLocked, HasBasePermission("change_project", "projects") | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), @@ -602,11 +617,11 @@ def get_queryset(self): redis_cache_view("locations_list_cache", settings.CACHE_LOCATIONS_LIST_TTL) ) def list(self, request, *args, **kwargs): - return super(LocationViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) @method_decorator(clear_cache_with_key("locations_list_cache")) def dispatch(self, request, *args, **kwargs): - return super(LocationViewSet, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) class HistoricalProjectViewSet(MultipleIDViewsetMixin, viewsets.ReadOnlyModelViewSet): @@ -662,14 +677,14 @@ def check_linked_project_permission(self, project): def perform_create(self, serializer): project = serializer.validated_data["project"] self.check_linked_project_permission(project) - super(LinkedProjectViewSet, self).perform_create(serializer) + super().perform_create(serializer) @transaction.atomic def perform_update(self, serializer): project = serializer.validated_data.get("project") if project: self.check_linked_project_permission(project) - super(LinkedProjectViewSet, self).perform_update(serializer) + super().perform_update(serializer) @extend_schema( request=ProjectAddLinkedProjectSerializer, responses=ProjectSerializer From 2ded7dcb7d684c8af1157ad9956cd088adc2529a Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 24 Apr 2026 15:10:55 +0200 Subject: [PATCH 03/24] add members and filter/irderubg --- apps/announcements/views.py | 4 ++-- apps/commons/views.py | 9 +++++++++ apps/feedbacks/filters.py | 8 +++++++- apps/feedbacks/views.py | 10 +++++++--- apps/invitations/views.py | 1 - apps/modules/project.py | 12 ++++++++++-- apps/projects/models.py | 7 ++++--- apps/projects/serializers.py | 14 +++++++++++++- apps/projects/urls.py | 4 ++++ apps/projects/views.py | 33 ++++++++++++++++++++++++++++++--- 10 files changed, 86 insertions(+), 16 deletions(-) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index c3c6f713..33e8e684 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -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/views.py b/apps/commons/views.py index 40dfe801..e384c2dc 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -156,6 +156,15 @@ def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) +class NestedProjectViewMixins: + def initial(self, request, *args, **kwargs): + self.project = get_object_or_404( + request.user.get_project_queryset().slug_or_id(kwargs["project_id"]), + ) + + super().initial(request, *args, **kwargs) + + 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/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/project.py b/apps/modules/project.py index fd020fef..864060c1 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -13,10 +13,18 @@ class ProjectModules(AbstractModules): instance: Project def members(self) -> QuerySet[ProjectUser]: - return self.instance.get_all_members().filter( - pk__in=self.user.get_user_queryset() + + owners = self.instance.get_owners().users.all().annotate(role=Value("owners")) + reviewers = ( + self.instance.get_reviewers().users.all().annotate(role=Value("reviewers")) + ) + members = ( + self.instance.get_members().users.all().annotate(role=Value("members")) ) + all_members = owners | reviewers | members + return all_members.filter(pk__in=self.user.get_user_queryset()) + def groups(self) -> QuerySet[PeopleGroup]: return self.instance.get_all_groups().filter( pk__in=self.user.get_people_group_queryset() diff --git a/apps/projects/models.py b/apps/projects/models.py index e6ef7d2b..ab189d2b 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -14,7 +14,6 @@ from django.db.models import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords, HistoricForeignKey from apps.analytics.models import Stat @@ -29,7 +28,9 @@ 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 from .exceptions import WrongProjectOrganizationError @@ -48,7 +49,7 @@ def uuid_generator() -> str: return shortuuid.ShortUUID().random(length=8) -class SoftDeleteManager(models.Manager): +class SoftDeleteManager(MultipleIdsQuerySet): """Exclude by default soft-deleted Projects.""" def get_queryset(self): @@ -221,7 +222,7 @@ class LifeStatus(models.TextChoices): max_length=8, null=True, blank=True, default=None ) permissions_up_to_date = models.BooleanField(default=False) - objects = SoftDeleteManager() + objects = SoftDeleteManager.as_manager() class Meta: write_only_subscopes = ( diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index cf16fd46..fb50fea2 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -6,12 +6,13 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import serializers -from services.translator.serializers import auto_translated from apps.accounts.models import AnonymousUser, PeopleGroup, ProjectUser from apps.accounts.serializers import ( PeopleGroupLightSerializer, UserLighterSerializer, + UserLightSerializer, + UserSerializer, ) from apps.announcements.serializers import AnnouncementSerializer from apps.commons.fields import ( @@ -45,6 +46,7 @@ ) from apps.skills.models import Tag from apps.skills.serializers import TagRelatedField, TagSerializer +from services.translator.serializers import auto_translated from .exceptions import ( AddProjectToOrganizationPermissionError, @@ -537,6 +539,16 @@ def to_internal_value(self, data): return serializers.PrimaryKeyRelatedField.to_internal_value(self, data) +class ProjectTeamMembersSerializer(UserLightSerializer): + role = serializers.SerializerMethodField() + + class Meta(UserLightSerializer.Meta): + fields = UserLightSerializer.Meta.fields + ("role",) + + def get_role(self, instance: ProjectUser): + return instance.role + + class ProjectAddTeamMembersSerializer(serializers.Serializer): project = HiddenPrimaryKeyRelatedField( required=False, write_only=True, queryset=Project.objects.all() diff --git a/apps/projects/urls.py b/apps/projects/urls.py index d9151080..a639d6fd 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -20,6 +20,7 @@ HistoricalProjectViewSet, LinkedProjectViewSet, LocationViewSet, + MembersProjectViewSet, ProjectHeaderView, ProjectImagesView, ProjectMessageImagesView, @@ -41,6 +42,9 @@ project_router_register( router, r"history", HistoricalProjectViewSet, basename="Project-versions" ) +project_router_register( + router, r"members", MembersProjectViewSet, basename="Project-members" +) project_router_register(router, r"blog-entry", BlogEntryViewSet, basename="BlogEntry") project_router_register( router, diff --git a/apps/projects/views.py b/apps/projects/views.py index d5a10f2c..f9087ec1 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -18,7 +18,6 @@ IsAuthenticatedOrReadOnly, ) from rest_framework.response import Response -from services.mistral.models import ProjectEmbedding from simple_history.utils import update_change_reason from apps.accounts.models import PeopleGroupLocation @@ -31,6 +30,7 @@ from apps.commons.views import ( MultipleIDViewsetMixin, NestedOrganizationViewMixins, + NestedProjectViewMixins, ) from apps.files.models import Image from apps.files.views import ImageStorageView @@ -56,6 +56,7 @@ LinkedProjectPermissionDeniedError, OrganizationsParameterMissing, ) +from services.mistral.models import ProjectEmbedding from .filters import ProjectFilter from .models import ( @@ -83,6 +84,7 @@ ProjectSerializer, ProjectTabItemSerializer, ProjectTabSerializer, + ProjectTeamMembersSerializer, ProjectVersionListSerializer, ProjectVersionSerializer, ) @@ -492,9 +494,35 @@ def add_image_to_model(self, image, *args, **kwargs): return None +class MembersProjectViewSet( + NestedProjectViewMixins, + MultipleIDViewsetMixin, + viewsets.ModelViewSet, +): + serializer_class = ProjectTeamMembersSerializer + filter_backends = [DjangoFilterBackend, OrderingFilter] + lookup_field = "id" + lookup_value_regex = "[0-9]+" + permission_classes = [ + IsAuthenticatedOrReadOnly, + ProjectIsNotLocked, + ReadOnly + | HasBasePermission("change_project", "projects") + | HasOrganizationPermission("change_project") + | HasProjectPermission("change_project"), + ] + multiple_lookup_fields = [(Project, "project_id")] + + def get_queryset(self) -> QuerySet: + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + return modules.members() + + class BlogEntryViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): serializer_class = BlogEntrySerializer - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering_fields = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[0-9]+" permission_classes = [ @@ -648,7 +676,6 @@ def get_queryset(self) -> QuerySet: class LinkedProjectViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): serializer_class = LinkedProjectSerializer - http_method_names = ["post", "patch", "delete"] lookup_field = "id" lookup_value_regex = "[0-9]+" permission_classes = [ From a98d31fc05c7a4a551adb0758a3fffc27d957a3f Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 24 Apr 2026 15:39:35 +0200 Subject: [PATCH 04/24] fix: filters --- apps/projects/filters.py | 8 ++++++++ apps/projects/models.py | 8 ++++---- apps/projects/views.py | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/projects/filters.py b/apps/projects/filters.py index dd4cb47b..31728504 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -102,3 +102,11 @@ 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",) diff --git a/apps/projects/models.py b/apps/projects/models.py index ab189d2b..ed3b3824 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -54,17 +54,17 @@ class SoftDeleteManager(MultipleIdsQuerySet): def get_queryset(self): """Exclude by default soft-deleted Projects.""" - return super().get_queryset().filter(deleted_at=None) + return self.filter(deleted_at=None) def all_with_delete(self, pk=None): """Retrieve all projects, or the one corresponding to `pk` if given.""" if pk is None: - return super().get_queryset() - return super().get_queryset().get(pk=pk) + return self.get_queryset() + return self.get_queryset().get(pk=pk) def deleted_projects(self): """Retrieve all soft-deleted projects.""" - return super().get_queryset().exclude(deleted_at=None) + return self.get_queryset().exclude(deleted_at=None) class Project( diff --git a/apps/projects/views.py b/apps/projects/views.py index f9087ec1..fcddbcf8 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -58,7 +58,7 @@ ) from services.mistral.models import ProjectEmbedding -from .filters import ProjectFilter +from .filters import ProjectFilter, ProjectMembersFilter from .models import ( BlogEntry, Goal, @@ -501,7 +501,9 @@ class MembersProjectViewSet( ): serializer_class = ProjectTeamMembersSerializer filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ProjectMembersFilter lookup_field = "id" + ordering_fields = ("role",) lookup_value_regex = "[0-9]+" permission_classes = [ IsAuthenticatedOrReadOnly, From fffef476fc81d9555a810501f9c9be060d4d2d6a Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 27 Apr 2026 15:09:45 +0200 Subject: [PATCH 05/24] announcements --- apps/announcements/views.py | 3 +-- apps/modules/project.py | 11 ++++++++++- apps/projects/models.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index 33e8e684..84e307ae 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -31,8 +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 = ["updated_at"] + ordering_fields = ["updated_at", "created_at", "deadline"] permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, diff --git a/apps/modules/project.py b/apps/modules/project.py index 864060c1..1afad283 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -5,7 +5,13 @@ 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 +from apps.projects.models import ( + BlogEntry, + Goal, + Location, + Project, + ProjectMessage, +) @register_module(Project) @@ -61,3 +67,6 @@ def announcements(self) -> QuerySet[Announcement]: def reviews(self) -> QuerySet[Review]: return self.instance.reviews.all() + + def messages(self) -> QuerySet[ProjectMessage]: + return self.instance.messages.all() diff --git a/apps/projects/models.py b/apps/projects/models.py index ed3b3824..563c9fd3 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -927,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 ---------- From 05fc6f62f33c4577d1b3caca9646e10babc59296 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 7 May 2026 12:26:29 +0200 Subject: [PATCH 06/24] fix: members reviews --- apps/modules/project.py | 24 ++++++++++++++++-------- apps/projects/views.py | 16 ---------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/modules/project.py b/apps/modules/project.py index 1afad283..b70db68c 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -2,6 +2,7 @@ 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 @@ -20,16 +21,23 @@ class ProjectModules(AbstractModules): def members(self) -> QuerySet[ProjectUser]: - owners = self.instance.get_owners().users.all().annotate(role=Value("owners")) - reviewers = ( - self.instance.get_reviewers().users.all().annotate(role=Value("reviewers")) - ) - members = ( - self.instance.get_members().users.all().annotate(role=Value("members")) + owners = self.instance.get_owners().users.all() + reviewers = self.instance.get_reviewers().users.all() + members = self.instance.get_members().users.all() + + all_members = ( + self.instance.get_all_members() + .filter(pk__in=self.user.get_user_queryset()) + .annotate( + role=Case( + When(pk__in=owners, then=Value(GroupData.Role.OWNERS)), + When(pk__in=reviewers, then=Value(GroupData.Role.REVIEWERS)), + When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), + ) + ) ) - all_members = owners | reviewers | members - return all_members.filter(pk__in=self.user.get_user_queryset()) + return all_members def groups(self) -> QuerySet[PeopleGroup]: return self.instance.get_all_groups().filter( diff --git a/apps/projects/views.py b/apps/projects/views.py index fcddbcf8..04b6c88b 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -220,22 +220,6 @@ def duplicate(self, request, *args, **kwargs): status=status.HTTP_201_CREATED, ) - @action( - detail=False, - methods=["GET", "LIST"], - url_path="member", - permission_classes=[ - IsAuthenticated, - ProjectIsNotLocked, - ], - ) - def members(self, request, *ar, **kwargs): - project = self.get_object() - modules_manager = project.get_related_module() - modules = modules_manager(project, request.user) - - return modules.members() - @extend_schema(request=ProjectAddTeamMembersSerializer, responses=ProjectSerializer) @action( detail=True, From 5d54c14158ee50242f93c6c64fac13ad932ddf17 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 13 May 2026 09:42:06 +0200 Subject: [PATCH 07/24] fix: similars --- apps/modules/project.py | 38 ++++++++++++++++++++++++++++++++------ apps/projects/views.py | 23 +++++++++-------------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/apps/modules/project.py b/apps/modules/project.py index b70db68c..c5e0f638 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -21,18 +21,44 @@ class ProjectModules(AbstractModules): def members(self) -> QuerySet[ProjectUser]: - owners = self.instance.get_owners().users.all() - reviewers = self.instance.get_reviewers().users.all() - members = self.instance.get_members().users.all() + owners = self.instance.owners.all() + reviewers = self.instance.reviewers.all() + members = self.instance.members.all() + owner_groups_users = self.instance.owner_groups_users.all() + member_groups_users = self.instance.member_groups_users.all() + reviewer_groups_users = self.instance.reviewer_groups_users.all() + + all_members = ( + owners + | reviewers + | members + | owner_groups_users + | member_groups_users + | reviewer_groups_users + ) all_members = ( - self.instance.get_all_members() + 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=reviewers, then=Value(GroupData.Role.REVIEWERS)), When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), + # group + When( + pk__in=owner_groups_users, + then=Value(GroupData.Role.OWNER_GROUPS), + ), + When( + pk__in=reviewer_groups_users, + then=Value(GroupData.Role.REVIEWER_GROUPS), + ), + When( + pk__in=member_groups_users, + then=Value(GroupData.Role.MEMBER_GROUPS), + ), + default=Value(None), ) ) ) @@ -49,8 +75,8 @@ def linked_projects(self) -> QuerySet[Project]: 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 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() diff --git a/apps/projects/views.py b/apps/projects/views.py index 04b6c88b..08ba4e99 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -384,26 +384,21 @@ def unlock(self, request, *args, **kwargs): ) @action(detail=True, methods=["GET"], permission_classes=[ReadOnly]) def similar(self, request, *args, **kwargs): - project = self.get_object() - embedding, _ = ProjectEmbedding.objects.get_or_create(item=project) - if embedding.embedding is None: - embedding = embedding.vectorize() - vector = embedding.embedding - if vector is None: - return Response([]) - organizations = [ - o for o in request.query_params.get("organizations", "").split(",") if o - ] + organizations = request.query_params.getlist("organizations") if not organizations: raise OrganizationsParameterMissing + + project = self.get_object() + modules_manager = project.get_related_module() + modules = modules_manager(project, request.user) + threshold = int(request.query_params.get("threshold", 5)) queryset = ( - self.request.user.get_project_queryset() + modules.similars() .filter(organizations__code__in=get_below_hierarchy_codes(organizations)) - .exclude(id=project.id) .prefetch_related("categories") - ) - queryset = ProjectEmbedding.vector_search(vector, queryset)[:threshold] + )[:threshold] + return Response(ProjectLightSerializer(queryset, many=True).data) From 5524c9a5fcdb5aebf83e58573e5c5ae658c10216 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 15 May 2026 17:13:30 +0200 Subject: [PATCH 08/24] get info fro modules --- apps/modules/project.py | 49 +++--- apps/projects/filters.py | 8 + apps/projects/serializers.py | 26 +-- apps/projects/urls.py | 8 +- apps/projects/views.py | 315 +++++++++++++++++++---------------- 5 files changed, 227 insertions(+), 179 deletions(-) diff --git a/apps/modules/project.py b/apps/modules/project.py index c5e0f638..a9ecd8ad 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -25,18 +25,7 @@ def members(self) -> QuerySet[ProjectUser]: reviewers = self.instance.reviewers.all() members = self.instance.members.all() - owner_groups_users = self.instance.owner_groups_users.all() - member_groups_users = self.instance.member_groups_users.all() - reviewer_groups_users = self.instance.reviewer_groups_users.all() - - all_members = ( - owners - | reviewers - | members - | owner_groups_users - | member_groups_users - | reviewer_groups_users - ) + all_members = owners | reviewers | members all_members = ( all_members.distinct() .filter(pk__in=self.user.get_user_queryset()) @@ -45,30 +34,36 @@ def members(self) -> QuerySet[ProjectUser]: When(pk__in=owners, then=Value(GroupData.Role.OWNERS)), When(pk__in=reviewers, then=Value(GroupData.Role.REVIEWERS)), When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), - # group - When( - pk__in=owner_groups_users, - then=Value(GroupData.Role.OWNER_GROUPS), - ), + ) + ) + ) + + return all_members + + def groups(self) -> QuerySet[PeopleGroup]: + owner_groups = self.instance.owner_groups.all() + reviewer_groups = self.instance.reviewer_groups.all() + member_groups = self.instance.member_groups.all() + + all_groups = owner_groups | reviewer_groups | member_groups + all_groups = ( + all_groups.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=reviewer_groups_users, + pk__in=reviewer_groups, then=Value(GroupData.Role.REVIEWER_GROUPS), ), When( - pk__in=member_groups_users, - then=Value(GroupData.Role.MEMBER_GROUPS), + pk__in=member_groups, then=Value(GroupData.Role.MEMBER_GROUPS) ), - default=Value(None), ) ) ) - return all_members - - def groups(self) -> QuerySet[PeopleGroup]: - return self.instance.get_all_groups().filter( - pk__in=self.user.get_people_group_queryset() - ) + return all_groups def linked_projects(self) -> QuerySet[Project]: return self.instance.linked_projects.filter( diff --git a/apps/projects/filters.py b/apps/projects/filters.py index 31728504..8a1f5ef0 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -110,3 +110,11 @@ class ProjectMembersFilter(filters.FilterSet): class Meta: model = ProjectUser fields = ("role",) + + +class ProjectGroupsFilter(filters.FilterSet): + role = MultiValueCharFilter() + + class Meta: + model = PeopleGroup + fields = ("role",) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index fb50fea2..53a89222 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -6,15 +6,14 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import serializers +from services.translator.serializers import auto_translated -from apps.accounts.models import AnonymousUser, PeopleGroup, ProjectUser +from apps.accounts.models import PeopleGroup, ProjectUser from apps.accounts.serializers import ( PeopleGroupLightSerializer, UserLighterSerializer, UserLightSerializer, - UserSerializer, ) -from apps.announcements.serializers import AnnouncementSerializer from apps.commons.fields import ( HiddenPrimaryKeyRelatedField, RecursiveField, @@ -29,13 +28,9 @@ 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 @@ -45,8 +40,7 @@ ProjectTemplateSerializer, ) from apps.skills.models import Tag -from apps.skills.serializers import TagRelatedField, TagSerializer -from services.translator.serializers import auto_translated +from apps.skills.serializers import TagRelatedField from .exceptions import ( AddProjectToOrganizationPermissionError, @@ -549,6 +543,16 @@ def get_role(self, instance: ProjectUser): return instance.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() diff --git a/apps/projects/urls.py b/apps/projects/urls.py index a639d6fd..a6ec35fb 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -20,9 +20,10 @@ HistoricalProjectViewSet, LinkedProjectViewSet, LocationViewSet, - MembersProjectViewSet, + ProjectGroupsViewSet, ProjectHeaderView, ProjectImagesView, + ProjectMembersViewSet, ProjectMessageImagesView, ProjectMessageViewSet, ProjectTabImagesView, @@ -43,7 +44,10 @@ router, r"history", HistoricalProjectViewSet, basename="Project-versions" ) project_router_register( - router, r"members", MembersProjectViewSet, basename="Project-members" + router, r"member", ProjectMembersViewSet, basename="Project-members" +) +project_router_register( + router, r"group", ProjectGroupsViewSet, basename="Project-groups" ) project_router_register(router, r"blog-entry", BlogEntryViewSet, basename="BlogEntry") project_router_register( diff --git a/apps/projects/views.py b/apps/projects/views.py index 08ba4e99..6f5873c3 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -18,6 +18,7 @@ IsAuthenticatedOrReadOnly, ) from rest_framework.response import Response +from services.mistral.models import ProjectEmbedding from simple_history.utils import update_change_reason from apps.accounts.models import PeopleGroupLocation @@ -56,9 +57,8 @@ LinkedProjectPermissionDeniedError, OrganizationsParameterMissing, ) -from services.mistral.models import ProjectEmbedding -from .filters import ProjectFilter, ProjectMembersFilter +from .filters import ProjectFilter, ProjectGroupsFilter, ProjectMembersFilter from .models import ( BlogEntry, Goal, @@ -77,6 +77,7 @@ LocationSerializer, ProjectAddLinkedProjectSerializer, ProjectAddTeamMembersSerializer, + ProjectGroupSerializer, ProjectLightSerializer, ProjectMessageSerializer, ProjectRemoveLinkedProjectSerializer, @@ -220,89 +221,6 @@ def duplicate(self, request, *args, **kwargs): status=status.HTTP_201_CREATED, ) - @extend_schema(request=ProjectAddTeamMembersSerializer, responses=ProjectSerializer) - @action( - detail=True, - methods=["POST"], - url_path="member/add", - permission_classes=[ - IsAuthenticated, - HasBasePermission("change_project", "projects") - | HasOrganizationPermission("change_project") - | HasProjectPermission("change_project"), - ], - ) - @transaction.atomic - def add_member(self, request, *args, **kwargs): - """Add users to the project's group of the given name or add group to project.member_groups.""" - project = self.get_object() - serializer = ProjectAddTeamMembersSerializer( - data={"project": project.pk, **request.data} - ) - serializer.is_valid(raise_exception=True) - instances = serializer.save() - self.notify_add_members(instances) - project.refresh_from_db() - project._change_reason = "Added members" - project.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - def notify_add_members(self, instances): - for instance in instances: - if instance["type"] == "projectuser": - notification = ( - notify_member_added - if instance["created"] - else notify_member_updated - ) - notification.delay( - instance["project"].pk, - instance["user"].pk, - self.request.user.pk, - instance["role"], - ) - if instance["type"] == "peoplegroup" and instance["created"]: - notify_group_as_member_added.delay( - instance["project"].pk, - instance["people_group"].pk, - self.request.user.pk, - instance["role"], - ) - - @extend_schema( - request=ProjectRemoveTeamMembersSerializer, responses=ProjectSerializer - ) - @action( - detail=True, - methods=["POST"], - url_path="member/remove", - permission_classes=[ - IsAuthenticated, - ProjectIsNotLocked, - HasBasePermission("change_project", "projects") - | HasOrganizationPermission("change_project") - | HasProjectPermission("change_project"), - ], - ) - @transaction.atomic - def remove_member(self, request, *args, **kwargs): - """Remove users from the project's group of the given name.""" - project = self.get_object() - # The following 3 lines are here for backward compatibility - data = request.data.copy() - if "user" in data and "users" not in data: - data = {"users": [data["user"]]} - serializer = ProjectRemoveTeamMembersSerializer( - data={"project": project.pk, **data} - ) - serializer.is_valid(raise_exception=True) - instances = serializer.save() - self.notify_remove_members(instances) - project.refresh_from_db() - project._change_reason = "Removed members" - project.save() - return Response(status=status.HTTP_204_NO_CONTENT) - @action( detail=True, methods=["DELETE"], @@ -376,8 +294,9 @@ def unlock(self, request, *args, **kwargs): ), OpenApiParameter( name="organizations", - description="Comma-separated list of organization codes.", + description="list of organization codes.", required=False, + many=True, type=str, ), ], @@ -473,7 +392,7 @@ def add_image_to_model(self, image, *args, **kwargs): return None -class MembersProjectViewSet( +class ProjectMembersViewSet( NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet, @@ -499,8 +418,119 @@ def get_queryset(self) -> QuerySet: modules = modules_manager(self.project, self.request.user) return modules.members() + @extend_schema(request=ProjectAddTeamMembersSerializer, responses=ProjectSerializer) + @action( + detail=False, + methods=["POST"], + url_path="add", + permission_classes=[ + IsAuthenticated, + HasBasePermission("change_project", "projects") + | HasOrganizationPermission("change_project") + | HasProjectPermission("change_project"), + ], + ) + @transaction.atomic + def add_member(self, request, *args, **kwargs): + """Add users to the project's group of the given name or add group to project.member_groups.""" + serializer = ProjectAddTeamMembersSerializer( + data={"project": self.project.pk, **request.data} + ) + serializer.is_valid(raise_exception=True) + instances = serializer.save() + self.notify_add_members(instances) + self.project.refresh_from_db() + self.project._change_reason = "Added members" + self.project.save() + + return Response(status=status.HTTP_204_NO_CONTENT) -class BlogEntryViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): + def notify_add_members(self, instances): + for instance in instances: + if instance["type"] == "projectuser": + notification = ( + notify_member_added + if instance["created"] + else notify_member_updated + ) + notification.delay( + instance["project"].pk, + instance["user"].pk, + self.request.user.pk, + instance["role"], + ) + if instance["type"] == "peoplegroup" and instance["created"]: + notify_group_as_member_added.delay( + instance["project"].pk, + instance["people_group"].pk, + self.request.user.pk, + instance["role"], + ) + + @extend_schema( + request=ProjectRemoveTeamMembersSerializer, responses=ProjectSerializer + ) + @action( + detail=False, + methods=["POST"], + url_path="remove", + permission_classes=[ + IsAuthenticated, + ProjectIsNotLocked, + HasBasePermission("change_project", "projects") + | HasOrganizationPermission("change_project") + | HasProjectPermission("change_project"), + ], + ) + @transaction.atomic + def remove_member(self, request, *args, **kwargs): + """Remove users from the project's group of the given name.""" + # The following 3 lines are here for backward compatibility + data = request.data.copy() + if "user" in data and "users" not in data: + data = {"users": [data["user"]]} + serializer = ProjectRemoveTeamMembersSerializer( + data={"project": self.project.pk, **data} + ) + serializer.is_valid(raise_exception=True) + instances = serializer.save() + self.notify_remove_members(instances) + self.project.refresh_from_db() + self.project._change_reason = "Removed members" + self.project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectGroupsViewSet( + NestedProjectViewMixins, + MultipleIDViewsetMixin, + viewsets.ModelViewSet, +): + serializer_class = ProjectGroupSerializer + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ProjectGroupsFilter + lookup_field = "id" + ordering_fields = ("role",) + lookup_value_regex = "[0-9]+" + permission_classes = [ + IsAuthenticatedOrReadOnly, + ProjectIsNotLocked, + ReadOnly + | HasBasePermission("change_project", "projects") + | HasOrganizationPermission("change_project") + | HasProjectPermission("change_project"), + ] + multiple_lookup_fields = [(Project, "project_id")] + + def get_queryset(self) -> QuerySet: + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + return modules.groups() + + +class BlogEntryViewSet( + NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet +): serializer_class = BlogEntrySerializer filter_backends = [DjangoFilterBackend, OrderingFilter] ordering_fields = ("created_at", "updated_at") @@ -517,22 +547,19 @@ class BlogEntryViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: - if "project_id" in self.kwargs: - return ( - self.request.user.get_project_related_queryset( - BlogEntry.objects.filter(project=self.kwargs["project_id"]) - ) - .prefetch_related("images") - .select_related("project") - ) - return BlogEntry.objects.none() + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + + return modules.blogs().prefetch_related("images").select_related("project") def perform_create(self, serializer): instance = serializer.save() notify_new_blogentry.delay(instance.pk, self.request.user.pk) -class BlogEntryImagesView(MultipleIDViewsetMixin, ImageStorageView): +class BlogEntryImagesView( + NestedProjectViewMixins, MultipleIDViewsetMixin, ImageStorageView +): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -545,16 +572,14 @@ class BlogEntryImagesView(MultipleIDViewsetMixin, ImageStorageView): multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - if "project_id" in self.kwargs: - qs = self.request.user.get_project_related_queryset( - Image.objects.filter(blog_entries__project=self.kwargs["project_id"]), - project_related_name="blog_entries__project", - ) - # Retrieve images before blog entry is posted - if self.request.user.is_authenticated: - qs = qs | Image.objects.filter(owner=self.request.user) - return qs.distinct() - return Image.objects.none() + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + + qs = Image.objects.filter(pk__in=modules.blog()) + # Retrieve images before blog entry is posted + if self.request.user.is_authenticated: + qs = qs | Image.objects.filter(owner=self.request.user) + return qs.distinct() @staticmethod def upload_to(instance, filename) -> str: @@ -579,7 +604,9 @@ def add_image_to_model(self, image, *args, **kwargs): return None -class GoalViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class GoalViewSet( + NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet +): serializer_class = GoalSerializer filter_backends = [DjangoFilterBackend] lookup_field = "id" @@ -595,13 +622,15 @@ class GoalViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: - if "project_id" in self.kwargs: - qs = self.request.user.get_project_related_queryset(Goal.objects.all()) - return qs.filter(project=self.kwargs["project_id"]) - return Goal.objects.none() + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + return modules.goals() -class LocationViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): + +class LocationViewSet( + NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet +): serializer_class = LocationSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -617,10 +646,10 @@ class LocationViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - qs = self.request.user.get_project_related_queryset(Location.objects) - if "project_id" in self.kwargs: - qs = qs.filter(project=self.kwargs["project_id"]) - return qs.select_related("project") + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + + return modules.locations() @method_decorator( redis_cache_view("locations_list_cache", settings.CACHE_LOCATIONS_LIST_TTL) @@ -655,7 +684,9 @@ def get_queryset(self) -> QuerySet: return apps.get_model("projects", "HistoricalProject").objects.none() -class LinkedProjectViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class LinkedProjectViewSet( + NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet +): serializer_class = LinkedProjectSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -670,12 +701,10 @@ class LinkedProjectViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - if "project_id" in self.kwargs: - qs = self.request.user.get_project_related_queryset( - LinkedProject.objects.all(), project_related_name="target" - ) - return qs.filter(target__id=self.kwargs["project_id"]) - return LinkedProject.objects.none() + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + + return modules.linked_projects() def check_linked_project_permission(self, project): if not self.request.user.can_see_project(project): @@ -718,6 +747,7 @@ def add_many(self, request, *args, **kwargs): serializer = LinkedProjectSerializer(data=linked_project) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + context = {"request": request} return Response( ProjectSerializer(target, context=context).data, @@ -764,7 +794,9 @@ def delete_many(self, request, *args, **kwargs): ) -class ProjectMessageViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectMessageViewSet( + NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet +): serializer_class = ProjectMessageSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -783,15 +815,16 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): - if "project_id" in self.kwargs: - # get_project_related_queryset is not needed because the publication_status is not checked here - queryset = ProjectMessage.objects.filter(project=self.kwargs["project_id"]) - if self.action in ["retrieve", "list"]: - queryset = queryset.exclude(reply_on__isnull=False) - return queryset.select_related("author").prefetch_related( - "replies", "images" - ) - return ProjectMessage.objects.none() + # get_project_related_queryset is not needed because the publication_status is not checked here + + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + queryset = modules.messages() + + if self.action in ["retrieve", "list"]: + queryset = queryset.exclude(reply_on__isnull=False) + + return queryset.select_related("author").prefetch_related("replies", "images") def perform_create(self, serializer): message = serializer.save( @@ -803,7 +836,9 @@ def perform_destroy(self, instance: ProjectMessage): instance.soft_delete() -class ProjectMessageImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectMessageImagesView( + NestedProjectViewMixins, MultipleIDViewsetMixin, ImageStorageView +): multiple_lookup_fields = [(Project, "project_id")] def get_permissions(self): @@ -819,10 +854,12 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): + modules_manager = self.project.get_related_module() + modules = modules_manager(self.project, self.request.user) + queryset = modules.messages() + if "project_id" in self.kwargs: - qs = Image.objects.filter( - project_messages__project=self.kwargs["project_id"] - ) + qs = Image.objects.filter(project_messages__in=queryset) # Retrieve images before message is posted if self.request.user.is_authenticated: qs = qs | Image.objects.filter(owner=self.request.user) From 17daac0cfe4499f77ae5af124ec95458a4644a9d Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 19 May 2026 18:30:04 +0200 Subject: [PATCH 09/24] fix: search api --- apps/modules/serializers.py | 13 ++++++++----- apps/projects/views.py | 20 ++++++++++---------- apps/search/views.py | 18 ++++++++++++------ 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/modules/serializers.py b/apps/modules/serializers.py index 33607207..e59ac544 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() @@ -21,13 +22,15 @@ def __init__(self, *ar, **kw): # if modules is not set, get "default" values from Meta serializer if modules_keys is None: modules_keys = getattr(self.Meta, "modules_keys", None) + print(modules_keys, type(self)) 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) + res = modules_manager(instance, user=request.user).count(self.__modules_keys) + return res diff --git a/apps/projects/views.py b/apps/projects/views.py index 6f5873c3..e90ac3da 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -240,16 +240,6 @@ def remove_self(self, request, *args, **kwargs): project.save() return Response(status=status.HTTP_204_NO_CONTENT) - def notify_remove_members(self, instances): - for user in instances["users"]: - notify_member_deleted.delay( - instances["project"].pk, user.pk, self.request.user.pk - ) - for people_group in instances["people_groups"]: - notify_group_member_deleted.delay( - instances["project"].pk, people_group.pk, self.request.user.pk - ) - def _toggle_is_locked(self, value): project = self.get_object() project.is_locked = value @@ -445,6 +435,16 @@ def add_member(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) + def notify_remove_members(self, instances): + for user in instances["users"]: + notify_member_deleted.delay( + instances["project"].pk, user.pk, self.request.user.pk + ) + for people_group in instances["people_groups"]: + notify_group_member_deleted.delay( + instances["project"].pk, people_group.pk, self.request.user.pk + ) + def notify_add_members(self, instances): for instance in instances: if instance["type"] == "projectuser": diff --git a/apps/search/views.py b/apps/search/views.py index e432933c..166c734f 100644 --- a/apps/search/views.py +++ b/apps/search/views.py @@ -3,6 +3,7 @@ from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter from rest_framework.settings import api_settings from apps.commons.views import ListViewSet @@ -17,7 +18,8 @@ class SearchViewSet(ListViewSet): filterset_class = SearchObjectFilter serializer_class = SearchObjectSerializer - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering_fields = ("type", "last_update") def get_queryset(self, order: bool = True) -> QuerySet[SearchObject]: groups = self.request.user.get_people_group_queryset() @@ -71,6 +73,14 @@ def get_queryset(self, order: bool = True) -> QuerySet[SearchObject]: required=False, type=str, ), + OpenApiParameter( + name="types", + description="The type of data to search", + required=False, + many=True, + type=str, + enum=("project", "user", "people_group"), + ), ], ) @action(detail=False, methods=["GET"], url_path="(?P.+)") @@ -84,11 +94,7 @@ def search(self, request, *args, **kwargs): query = self.kwargs.get("search", "") indices = [ f"{settings.OPENSEARCH_INDEX_PREFIX}-{index}" - for index in ( - request.query_params.get("types", "project,user,people_group").split( - "," - ) - ) + for index in request.query_params.getlist("types") ] limit = request.query_params.get("limit", api_settings.PAGE_SIZE) offset = request.query_params.get("offset", 0) From 5dee807622eec48f887ae92055bfe8c4b304ef26 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 20 May 2026 15:03:51 +0200 Subject: [PATCH 10/24] clean role group members --- apps/accounts/serializers.py | 21 ++++------ .../accounts/tests/views/test_people_group.py | 24 ++++++++---- apps/modules/group.py | 39 ++++++++++++------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 5b883309..c6dd6823 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers +from services.crisalid.serializers import ResearcherSerializerLight +from services.translator.serializers import auto_translated from apps.commons.fields import ( HiddenPrimaryKeyRelatedField, @@ -28,8 +30,6 @@ from apps.projects.models import Project from apps.skills.models import Skill from apps.skills.serializers import SkillLightSerializer, TagRelatedField -from services.crisalid.serializers import ResearcherSerializerLight -from services.translator.serializers import auto_translated from .exceptions import ( FeaturedProjectPermissionDeniedError, @@ -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, } @@ -608,7 +605,7 @@ def create(self, validated_data): featured_projects = validated_data.pop("featured_projects", []) locations = validated_data.pop("locations", []) - people_group = super(PeopleGroupSerializer, self).create(validated_data) + people_group = super().create(validated_data) PeopleGroupAddTeamMembersSerializer().create( {"people_group": people_group, **team} ) @@ -628,7 +625,7 @@ def update(self, instance, validated_data): 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 @@ -825,8 +822,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 +959,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..d54ca662 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -1068,29 +1068,37 @@ def test_annotate_members(self): 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)) + self.assertTrue( + all(user["role"] == GroupData.Role.MANAGERS for user in batch_1) + ) + self.assertTrue(all(user["role"] == GroupData.Role.LEADERS 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)) + self.assertTrue( + all(user["role"] == GroupData.Role.MANAGERS for user in batch_2) + ) + self.assertTrue(all(user["role"] == GroupData.Role.LEADERS 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)) + self.assertTrue( + all(user["role"] == GroupData.Role.MANAGERS for user in batch_3) + ) + self.assertTrue(all(user["role"] != GroupData.Role.LEADERS 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.assertTrue( + all(user["role"] == GroupData.Role.MANAGERS for user in batch_4) + ) + self.assertTrue(all(user["role"] != GroupData.Role.LEADERS for user in batch_4)) def test_root_group_creation(self): organization = OrganizationFactory() diff --git a/apps/modules/group.py b/apps/modules/group.py index dbc01d15..c4ce5a38 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -1,12 +1,13 @@ from django.db.models import Case, Prefetch, Q, QuerySet, Value, When +from services.crisalid.models import Document, DocumentTypeCentralized 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 from apps.projects.models import Location, Project from apps.skills.models import Skill -from services.crisalid.models import Document, DocumentTypeCentralized @register_module(PeopleGroup) @@ -18,25 +19,33 @@ def members(self) -> QuerySet[ProjectUser]: "skills", queryset=Skill.objects.select_related("tag") ) - return ( - self.instance.get_all_members() - .distinct() - .annotate( - is_leader=Case( - When(id__in=self.instance.leaders.all(), then=True), - default=Value(False), - ) - ) + leaders = self.instance.leaders.all() + managers = self.instance.managers.all() + members = self.instance.members.all() + + all_members = leaders | managers | members + all_members = ( + 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") ) + return all_members + def featured_projects(self) -> QuerySet[Project]: group_projects = Project.objects.filter( groups__people_groups=self.instance From 7135a30b92c0239bd10cdaba934bde312410ef3e Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 21 May 2026 18:04:43 +0200 Subject: [PATCH 11/24] optimize query --- apps/accounts/serializers.py | 28 ----------------- apps/feedbacks/serializers.py | 13 +++++++- apps/modules/project.py | 59 +++++++++++++---------------------- apps/projects/serializers.py | 11 +++---- apps/projects/views.py | 45 +++++++++++++------------- 5 files changed, 60 insertions(+), 96 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index c6dd6823..18a3ac3b 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -527,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), @@ -555,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 @@ -601,28 +588,15 @@ 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().create(validated_data) - PeopleGroupAddTeamMembersSerializer().create( - {"people_group": people_group, **team} - ) - PeopleGroupAddFeaturedProjectsSerializer().create( - { - "people_group": people_group, - "featured_projects": featured_projects, - } - ) 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().update(instance, validated_data) @@ -646,8 +620,6 @@ class Meta: "tags", "locations", "publication_status", - "team", - "featured_projects", ] diff --git a/apps/feedbacks/serializers.py b/apps/feedbacks/serializers.py index afe16f60..12206db8 100644 --- a/apps/feedbacks/serializers.py +++ b/apps/feedbacks/serializers.py @@ -1,6 +1,7 @@ from typing import Any, Optional from rest_framework import serializers +from services.translator.serializers import auto_translated from apps.accounts.serializers import UserLighterSerializer from apps.commons.fields import RecursiveField, WritableSerializerMethodField @@ -13,7 +14,6 @@ from apps.files.models import Image from apps.organizations.models import Organization from apps.projects.models import Project -from services.translator.serializers import auto_translated from .exceptions import ( CommentProjectPermissionDeniedError, @@ -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/modules/project.py b/apps/modules/project.py index a9ecd8ad..66c4a6b3 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -21,49 +21,34 @@ class ProjectModules(AbstractModules): def members(self) -> QuerySet[ProjectUser]: - owners = self.instance.owners.all() - reviewers = self.instance.reviewers.all() - members = self.instance.members.all() - - all_members = owners | reviewers | members - all_members = ( - 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=reviewers, then=Value(GroupData.Role.REVIEWERS)), - When(pk__in=members, then=Value(GroupData.Role.MEMBERS)), - ) - ) + # get all members and annote role + owners = self.instance.owners.all().annotate(role=Value(GroupData.Role.OWNERS)) + reviewers = self.instance.reviewers.all().annotate( + role=Value(GroupData.Role.REVIEWERS) + ) + members = self.instance.members.all().annotate( + role=Value(GroupData.Role.MEMBERS) ) - return all_members + # union all and filter by request.user + all_members = owners | reviewers | members + return all_members.filter(pk__in=self.user.get_user_queryset()).distinct() def groups(self) -> QuerySet[PeopleGroup]: - owner_groups = self.instance.owner_groups.all() - reviewer_groups = self.instance.reviewer_groups.all() - member_groups = self.instance.member_groups.all() - - all_groups = owner_groups | reviewer_groups | member_groups - all_groups = ( - all_groups.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=reviewer_groups, - then=Value(GroupData.Role.REVIEWER_GROUPS), - ), - When( - pk__in=member_groups, then=Value(GroupData.Role.MEMBER_GROUPS) - ), - ) - ) + owner_groups = self.instance.owner_groups.all().annotate( + role=Value(GroupData.Role.OWNER_GROUPS) + ) + reviewer_groups = self.instance.reviewer_groups.all().annotate( + role=Value(GroupData.Role.REVIEWER_GROUPS) + ) + member_groups = self.instance.member_groups.all().annotate( + role=Value(GroupData.Role.MEMBER_GROUPS) ) - return all_groups + all_groups = owner_groups | reviewer_groups | member_groups + return all_groups.filter( + pk__in=self.user.get_people_group_queryset() + ).distinct() def linked_projects(self) -> QuerySet[Project]: return self.instance.linked_projects.filter( diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 53a89222..107c810a 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -533,14 +533,11 @@ def to_internal_value(self, data): return serializers.PrimaryKeyRelatedField.to_internal_value(self, data) -class ProjectTeamMembersSerializer(UserLightSerializer): - role = serializers.SerializerMethodField() - - class Meta(UserLightSerializer.Meta): - fields = UserLightSerializer.Meta.fields + ("role",) +class ProjectTeamMembersSerializer(UserLighterSerializer): + role = serializers.CharField() - def get_role(self, instance: ProjectUser): - return instance.role + class Meta(UserLighterSerializer.Meta): + fields = UserLighterSerializer.Meta.fields + ("role",) class ProjectGroupSerializer(PeopleGroupLightSerializer): diff --git a/apps/projects/views.py b/apps/projects/views.py index e90ac3da..a31607a0 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -21,7 +21,7 @@ from services.mistral.models import ProjectEmbedding from simple_history.utils import update_change_reason -from apps.accounts.models import PeopleGroupLocation +from apps.accounts.models import PeopleGroupLocation, ProjectUser from apps.accounts.permissions import HasBasePermission from apps.accounts.serializers import PeopleGroupLocationSuperLightSerializer from apps.analytics.models import Stat @@ -83,6 +83,7 @@ ProjectRemoveLinkedProjectSerializer, ProjectRemoveTeamMembersSerializer, ProjectSerializer, + ProjectSuperLightSerializer, ProjectTabItemSerializer, ProjectTabSerializer, ProjectTeamMembersSerializer, @@ -273,7 +274,7 @@ def unlock(self, request, *args, **kwargs): return self._toggle_is_locked(value=False) @extend_schema( - responses=ProjectLightSerializer, + responses=ProjectSuperLightSerializer(many=True), parameters=[ OpenApiParameter( name="threshold", @@ -300,15 +301,13 @@ def similar(self, request, *args, **kwargs): project = self.get_object() modules_manager = project.get_related_module() modules = modules_manager(project, request.user) + queryset = modules.similars().filter( + organizations__code__in=get_below_hierarchy_codes(organizations) + ) - threshold = int(request.query_params.get("threshold", 5)) - queryset = ( - modules.similars() - .filter(organizations__code__in=get_below_hierarchy_codes(organizations)) - .prefetch_related("categories") - )[:threshold] - - return Response(ProjectLightSerializer(queryset, many=True).data) + page = self.paginate_queryset(queryset) + serializer = ProjectSuperLightSerializer(page, many=True) + return self.get_paginated_response(serializer.data) class ProjectHeaderView(MultipleIDViewsetMixin, ImageStorageView): @@ -403,7 +402,7 @@ class ProjectMembersViewSet( ] multiple_lookup_fields = [(Project, "project_id")] - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[ProjectUser]: modules_manager = self.project.get_related_module() modules = modules_manager(self.project, self.request.user) return modules.members() @@ -435,16 +434,6 @@ def add_member(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) - def notify_remove_members(self, instances): - for user in instances["users"]: - notify_member_deleted.delay( - instances["project"].pk, user.pk, self.request.user.pk - ) - for people_group in instances["people_groups"]: - notify_group_member_deleted.delay( - instances["project"].pk, people_group.pk, self.request.user.pk - ) - def notify_add_members(self, instances): for instance in instances: if instance["type"] == "projectuser": @@ -500,6 +489,16 @@ def remove_member(self, request, *args, **kwargs): self.project.save() return Response(status=status.HTTP_204_NO_CONTENT) + def notify_remove_members(self, instances): + for user in instances["users"]: + notify_member_deleted.delay( + instances["project"].pk, user.pk, self.request.user.pk + ) + for people_group in instances["people_groups"]: + notify_group_member_deleted.delay( + instances["project"].pk, people_group.pk, self.request.user.pk + ) + class ProjectGroupsViewSet( NestedProjectViewMixins, @@ -525,7 +524,7 @@ class ProjectGroupsViewSet( def get_queryset(self) -> QuerySet: modules_manager = self.project.get_related_module() modules = modules_manager(self.project, self.request.user) - return modules.groups() + return modules.groups().select_related("organization") class BlogEntryViewSet( @@ -550,7 +549,7 @@ def get_queryset(self) -> QuerySet: modules_manager = self.project.get_related_module() modules = modules_manager(self.project, self.request.user) - return modules.blogs().prefetch_related("images").select_related("project") + return modules.blogs().prefetch_related("images") def perform_create(self, serializer): instance = serializer.save() From 53e2758c86d3c410ca49b39ac14bdb52e25465c2 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 21 May 2026 19:24:49 +0200 Subject: [PATCH 12/24] fix: duplicate linked --- apps/projects/serializers.py | 38 ++++++++++++++++++++---------------- apps/projects/views.py | 19 ++++++++---------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 107c810a..8c434bd0 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 services.translator.serializers import auto_translated from apps.accounts.models import PeopleGroup, ProjectUser from apps.accounts.serializers import ( PeopleGroupLightSerializer, UserLighterSerializer, - UserLightSerializer, ) from apps.commons.fields import ( HiddenPrimaryKeyRelatedField, @@ -479,6 +479,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 @@ -486,31 +495,26 @@ 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 -class ProjectIdSerializer(serializers.Serializer): - """Used to retrieve a project from their `id`.""" + def create(self, validate_data: dict) -> LinkedProject: + linked, _ = LinkedProject.objects.get_or_create( + project=validate_data["project"], + target=validate_data["target"], + ) + return linked - project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all()) + def update(self, instance, validate_data: dict) -> LinkedProject: + return instance class UserLighterSerializerKeycloakRelatedField( diff --git a/apps/projects/views.py b/apps/projects/views.py index a31607a0..7838c7e5 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -75,7 +75,6 @@ GoalSerializer, LinkedProjectSerializer, LocationSerializer, - ProjectAddLinkedProjectSerializer, ProjectAddTeamMembersSerializer, ProjectGroupSerializer, ProjectLightSerializer, @@ -723,7 +722,7 @@ def perform_update(self, serializer): super().perform_update(serializer) @extend_schema( - request=ProjectAddLinkedProjectSerializer, responses=ProjectSerializer + request=LinkedProjectSerializer(many=True), responses=ProjectSerializer ) @action( detail=False, @@ -740,16 +739,14 @@ def perform_update(self, serializer): ) def add_many(self, request, *args, **kwargs): """Link projects to a given project.""" - target = Project.objects.get(id=self.kwargs["project_id"]) - with transaction.atomic(): - for linked_project in request.data["projects"]: - serializer = LinkedProjectSerializer(data=linked_project) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) + serializer = LinkedProjectSerializer( + data=request.data, many=True, context={"validate_unique": False} + ) + serializer.is_valid(raise_exception=True) + serializer.save(validate=False) - context = {"request": request} return Response( - ProjectSerializer(target, context=context).data, + serializer.data, status=status.HTTP_200_OK, ) @@ -779,7 +776,7 @@ def add_many(self, request, *args, **kwargs): ) def delete_many(self, request, *args, **kwargs): """Unlink projects from another projects.""" - project = Project.objects.get(id=self.kwargs["project_id"]) + project = self.project serializer = ProjectRemoveLinkedProjectSerializer(data=request.data) serializer.is_valid(raise_exception=True) From 38c4455b726149ad8587328913fbb35c2cd7d74b Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 21 May 2026 21:16:47 +0200 Subject: [PATCH 13/24] search filters/linked/follow cleanup --- apps/accounts/models.py | 34 +++++++++ apps/accounts/serializers.py | 4 +- apps/commons/filters.py | 13 +++- apps/commons/mixins.py | 10 +++ apps/feedbacks/serializers.py | 2 +- apps/modules/base.py | 1 - apps/modules/group.py | 6 +- apps/modules/project.py | 2 +- apps/modules/serializers.py | 3 +- apps/projects/serializers.py | 12 +-- apps/projects/views.py | 3 +- apps/search/filters.py | 140 ++++++++++++++++++++++++++++++++++ 12 files changed, 205 insertions(+), 25 deletions(-) diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 5d54fba7..fe1d4596 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -982,3 +982,37 @@ def __init__(self, invitation): def has_perm(self, perm, obj=None): return perm == "accounts.add_projectuser" + + +class InternalAdmin(AnonymousUser): + 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/serializers.py b/apps/accounts/serializers.py index 18a3ac3b..6d8440fd 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -6,8 +6,6 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers -from services.crisalid.serializers import ResearcherSerializerLight -from services.translator.serializers import auto_translated from apps.commons.fields import ( HiddenPrimaryKeyRelatedField, @@ -30,6 +28,8 @@ from apps.projects.models import Project from apps.skills.models import Skill from apps.skills.serializers import SkillLightSerializer, TagRelatedField +from services.crisalid.serializers import ResearcherSerializerLight +from services.translator.serializers import auto_translated from .exceptions import ( FeaturedProjectPermissionDeniedError, 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..70880e87 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 @@ -457,6 +458,15 @@ def get_related_module(self): return get_module(type(self)) + @cached_property + def modules(self): + from apps.accounts.models import InternalAdmin + + internaladmin = InternalAdmin() + + modules_manager = self.get_related_module() + return modules_manager(self, internaladmin) + class HasEmbedding: def vectorize(self): diff --git a/apps/feedbacks/serializers.py b/apps/feedbacks/serializers.py index 12206db8..d3dbf95c 100644 --- a/apps/feedbacks/serializers.py +++ b/apps/feedbacks/serializers.py @@ -1,7 +1,6 @@ from typing import Any, Optional from rest_framework import serializers -from services.translator.serializers import auto_translated from apps.accounts.serializers import UserLighterSerializer from apps.commons.fields import RecursiveField, WritableSerializerMethodField @@ -14,6 +13,7 @@ from apps.files.models import Image from apps.organizations.models import Organization from apps.projects.models import Project +from services.translator.serializers import auto_translated from .exceptions import ( CommentProjectPermissionDeniedError, diff --git a/apps/modules/base.py b/apps/modules/base.py index 6e42b612..8907ca4d 100644 --- a/apps/modules/base.py +++ b/apps/modules/base.py @@ -1,5 +1,4 @@ import inspect -from ast import Call from collections.abc import Callable from functools import cache diff --git a/apps/modules/group.py b/apps/modules/group.py index c4ce5a38..6604fe43 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -1,5 +1,4 @@ from django.db.models import Case, Prefetch, Q, QuerySet, Value, When -from services.crisalid.models import Document, DocumentTypeCentralized from apps.accounts.models import PeopleGroup, PeopleGroupLocation, ProjectUser from apps.commons.models import GroupData @@ -8,6 +7,7 @@ from apps.newsfeed.models import Event, EventLocation, NewsLocation from apps.projects.models import Location, Project from apps.skills.models import Skill +from services.crisalid.models import Document, DocumentTypeCentralized @register_module(PeopleGroup) @@ -24,7 +24,7 @@ def members(self) -> QuerySet[ProjectUser]: members = self.instance.members.all() all_members = leaders | managers | members - all_members = ( + return ( all_members.distinct() .filter(pk__in=self.user.get_user_queryset()) .annotate( @@ -44,8 +44,6 @@ def members(self) -> QuerySet[ProjectUser]: .prefetch_related(skills_prefetch, "groups") ) - return all_members - def featured_projects(self) -> QuerySet[Project]: group_projects = Project.objects.filter( groups__people_groups=self.instance diff --git a/apps/modules/project.py b/apps/modules/project.py index 66c4a6b3..2a8b6cc1 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -1,4 +1,4 @@ -from django.db.models import Case, Prefetch, Q, QuerySet, Value, When +from django.db.models import QuerySet, Value from apps.accounts.models import PeopleGroup, ProjectUser from apps.announcements.models import Announcement diff --git a/apps/modules/serializers.py b/apps/modules/serializers.py index e59ac544..2174f19c 100644 --- a/apps/modules/serializers.py +++ b/apps/modules/serializers.py @@ -32,5 +32,4 @@ def get_modules(self, instance): request = self.context.get("request") modules_manager = instance.get_related_module() - res = modules_manager(instance, user=request.user).count(self.__modules_keys) - return res + return modules_manager(instance, user=request.user).count(self.__modules_keys) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 8c434bd0..fd0aed63 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -7,7 +7,6 @@ from django.shortcuts import get_object_or_404 from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from services.translator.serializers import auto_translated from apps.accounts.models import PeopleGroup, ProjectUser from apps.accounts.serializers import ( @@ -41,6 +40,7 @@ ) from apps.skills.models import Tag from apps.skills.serializers import TagRelatedField +from services.translator.serializers import auto_translated from .exceptions import ( AddProjectToOrganizationPermissionError, @@ -191,16 +191,13 @@ class ProjectSerializer( string_images_view: str = "Project-images-detail" string_images_process_template: bool = True - # team = ProjectAddTeamMembersSerializer(required=False, source="*", write_only=True) tags = TagRelatedField(many=True, required=False) # read_only header_image = ImageSerializer(read_only=True) categories = ProjectCategoryLightSerializer(many=True, read_only=True) - # last_comment = serializers.SerializerMsethodField(read_only=True) organizations = OrganizationSerializer(many=True, read_only=True) - # images = ImageSerializer(many=True, read_only=True) template = ProjectTemplateSerializer(read_only=True) views = serializers.SerializerMethodField() is_followed = serializers.SerializerMethodField(read_only=True) @@ -276,13 +273,6 @@ class Meta: "modules", ] - # @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_is_followed(self, project: Project) -> dict[str, Any]: if "request" in self.context: user = self.context["request"].user diff --git a/apps/projects/views.py b/apps/projects/views.py index 7838c7e5..151ae3df 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -18,7 +18,6 @@ IsAuthenticatedOrReadOnly, ) from rest_framework.response import Response -from services.mistral.models import ProjectEmbedding from simple_history.utils import update_change_reason from apps.accounts.models import PeopleGroupLocation, ProjectUser @@ -57,11 +56,11 @@ LinkedProjectPermissionDeniedError, OrganizationsParameterMissing, ) +from services.mistral.models import ProjectEmbedding from .filters import ProjectFilter, ProjectGroupsFilter, ProjectMembersFilter from .models import ( BlogEntry, - Goal, LinkedProject, Location, Project, diff --git a/apps/search/filters.py b/apps/search/filters.py index 3a93bd12..67cd8429 100644 --- a/apps/search/filters.py +++ b/apps/search/filters.py @@ -3,9 +3,11 @@ from rest_framework.filters import SearchFilter from rest_framework.settings import api_settings +from apps.accounts.models import PeopleGroup from apps.commons.filters import MultiValueCharFilter, UserMultipleIDFilter from apps.commons.utils import ArrayPosition from apps.organizations.utils import get_below_hierarchy_codes +from apps.projects.models import Project from .interface import OpenSearchService from .models import SearchObject @@ -138,6 +140,26 @@ class SearchObjectFilter(filters.FilterSet): members = UserMultipleIDFilter(method="filter_members") tags = MultiValueCharFilter(method="filter_tags") + exclude_projects = MultiValueCharFilter(method="filter_exclude_projects") + exclude_projects_in_project = filters.CharFilter( + method="filter_exclude_projects_in_project" + ) + exclude_groups_in_project = filters.CharFilter( + method="filter_exclude_groups_in_project" + ) + exclude_users_in_project = filters.CharFilter( + method="filter_exclude_users_in_project" + ) + + exclude_groups = MultiValueCharFilter(method="filter_exclude_groups") + exclude_projects_in_group = filters.CharFilter( + method="filter_exclude_projects_in_group" + ) + exclude_groups_in_group = filters.CharFilter( + method="filter_exclude_groups_in_group" + ) + exclude_users_in_group = filters.CharFilter(method="filter_exclude_users_in_group") + def filter_organizations(self, queryset, name, value): return queryset.filter( Q(project__organizations__code__in=get_below_hierarchy_codes(value)) @@ -235,6 +257,116 @@ def filter_needs_mentor_on(self, queryset, name, value): | Q(type__in=unaffected_types) ).distinct() + # this is for projects + + def filter_exclude_projects(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PEOPLE_GROUP, + SearchObject.SearchObjectType.USER, + ] + projects = Project.objects.slug_or_ids(value) + return queryset.filter( + ~Q(project__in=projects) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_projects_in_project(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PEOPLE_GROUP, + SearchObject.SearchObjectType.USER, + ] + + project = Project.objects.slug_or_id(value).first() + if not project: + return queryset + linked = project.modules.linked_projects().values_list("project", flat=True) + + return queryset.filter( + ~Q(project__in=linked) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_groups_in_project(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PROJECT, + SearchObject.SearchObjectType.USER, + ] + project = Project.objects.slug_or_id(value).first() + if not project: + return queryset + groups = project.modules.groups() + + return queryset.filter( + ~Q(people_group__in=groups) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_users_in_project(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PROJECT, + SearchObject.SearchObjectType.PEOPLE_GROUP, + ] + project = Project.objects.slug_or_id(value).first() + if not project: + return queryset + users = project.modules.members() + + return queryset.filter( + ~Q(user__in=users) | Q(type__in=unaffected_types) + ).distinct() + + # this is for groups + + def filter_exclude_groups(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PROJECT, + SearchObject.SearchObjectType.USER, + ] + groups = PeopleGroup.objects.slug_or_ids(value) + return queryset.filter( + ~Q(people_group__in=groups) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_projects_in_group(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PEOPLE_GROUP, + SearchObject.SearchObjectType.USER, + ] + + group = PeopleGroup.objects.slug_or_id(value).first() + if not group: + return queryset + featured_projects = group.modules.featured_projects() + + return queryset.filter( + ~Q(project__in=featured_projects) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_groups_in_group(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PROJECT, + SearchObject.SearchObjectType.USER, + ] + group = PeopleGroup.objects.slug_or_id(value).first() + if not group: + return queryset + groups = group.modules.subgroups() + + return queryset.filter( + ~Q(people_group__in=groups) | Q(type__in=unaffected_types) + ).distinct() + + def filter_exclude_users_in_group(self, queryset, name, value): + unaffected_types = [ + SearchObject.SearchObjectType.PROJECT, + SearchObject.SearchObjectType.PEOPLE_GROUP, + ] + group = PeopleGroup.objects.slug_or_id(value).first() + if not group: + return queryset + users = group.modules.members() + + return queryset.filter( + ~Q(user__in=users) | Q(type__in=unaffected_types) + ).distinct() + class Meta: model = SearchObject fields = [ @@ -250,4 +382,12 @@ class Meta: "needs_mentor", "can_mentor_on", "needs_mentor_on", + "exclude_projects", + "exclude_projects_in_project", + "exclude_groups_in_project", + "exclude_users_in_project", + "exclude_groups", + "exclude_projects_in_group", + "exclude_groups_in_group", + "exclude_users_in_group", ] From 5b509b3a769d2d6ff19906af23f34b3013d21454 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 22 May 2026 10:02:17 +0200 Subject: [PATCH 14/24] lint view --- apps/projects/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/projects/views.py b/apps/projects/views.py index 151ae3df..a2a92022 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -56,7 +56,6 @@ LinkedProjectPermissionDeniedError, OrganizationsParameterMissing, ) -from services.mistral.models import ProjectEmbedding from .filters import ProjectFilter, ProjectGroupsFilter, ProjectMembersFilter from .models import ( From 2ffbc4a5c6d4f4c7b2aca5b332bf33c3e0931903 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 26 May 2026 17:22:46 +0200 Subject: [PATCH 15/24] fix: members/groups queryset --- apps/modules/project.py | 78 ++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/apps/modules/project.py b/apps/modules/project.py index 2a8b6cc1..9c7e8aa5 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -1,4 +1,4 @@ -from django.db.models import QuerySet, Value +from django.db.models import Case, QuerySet, Value, When from apps.accounts.models import PeopleGroup, ProjectUser from apps.announcements.models import Announcement @@ -20,35 +20,65 @@ class ProjectModules(AbstractModules): instance: Project def members(self) -> QuerySet[ProjectUser]: - # get all members and annote role - owners = self.instance.owners.all().annotate(role=Value(GroupData.Role.OWNERS)) - reviewers = self.instance.reviewers.all().annotate( - role=Value(GroupData.Role.REVIEWERS) - ) - members = self.instance.members.all().annotate( - role=Value(GroupData.Role.MEMBERS) - ) + 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 | reviewers | members - return all_members.filter(pk__in=self.user.get_user_queryset()).distinct() + 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]: - owner_groups = self.instance.owner_groups.all().annotate( - role=Value(GroupData.Role.OWNER_GROUPS) - ) - reviewer_groups = self.instance.reviewer_groups.all().annotate( - role=Value(GroupData.Role.REVIEWER_GROUPS) - ) - member_groups = self.instance.member_groups.all().annotate( - role=Value(GroupData.Role.MEMBER_GROUPS) - ) + # 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() - all_groups = owner_groups | reviewer_groups | member_groups - return all_groups.filter( - pk__in=self.user.get_people_group_queryset() - ).distinct() + # 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( From 1311e287bfeb2241d4a9882010536fe8123c773c Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 26 May 2026 18:22:17 +0200 Subject: [PATCH 16/24] i18n --- locale/ca/LC_MESSAGES/django.po | 14 +++++++------- locale/de/LC_MESSAGES/django.po | 14 +++++++------- locale/en/LC_MESSAGES/django.po | 14 +++++++------- locale/es/LC_MESSAGES/django.po | 14 +++++++------- locale/et/LC_MESSAGES/django.po | 14 +++++++------- locale/fr/LC_MESSAGES/django.po | 14 +++++++------- locale/nl/LC_MESSAGES/django.po | 14 +++++++------- 7 files changed, 49 insertions(+), 49 deletions(-) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 6fd7fa60..2c3e7908 100644 --- a/locale/ca/LC_MESSAGES/django.po +++ b/locale/ca/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No pots assignar aquest rol a un usuari" msgid "You cannot assign this role to a user : {role}" msgstr "No pots assignar aquest rol a un usuari: {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "visibilitat" @@ -1568,23 +1568,23 @@ msgstr "Un missatge no pot ser una resposta a si mateix" msgid "You cannot change the type of a project's tab" msgstr "No pots canviar el tipus d'una pestanya d'un projecte" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "títol" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "objectiu principal" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "estat de vida" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "categories" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "objectius de desenvolupament sostenible" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 8f9d6056..9fb566c1 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Sie können diese Rolle keinem Benutzer zuweisen" msgid "You cannot assign this role to a user : {role}" msgstr "Sie können diese Rolle keinem Benutzer zuweisen: {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "Sichtbarkeit" @@ -1586,23 +1586,23 @@ msgstr "Eine Nachricht kann keine Antwort auf sich selbst sein" msgid "You cannot change the type of a project's tab" msgstr "Sie können den Typ eines Projekt-Tabs nicht ändern" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "Titel" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "Hauptziel" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "Lebensstatus" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "Kategorien" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "Ziele für nachhaltige Entwicklung" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 9911a0af..929a29e5 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "" msgid "You cannot assign this role to a user : {role}" msgstr "" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "" @@ -1225,23 +1225,23 @@ msgstr "" msgid "You cannot change the type of a project's tab" msgstr "" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index e129a5d9..59b0f80f 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No puedes asignar este rol a un usuario" msgid "You cannot assign this role to a user : {role}" msgstr "No puedes asignar este rol a un usuario: {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "visibilidad" @@ -1567,23 +1567,23 @@ msgstr "Un mensaje no puede ser una respuesta a sí mismo" msgid "You cannot change the type of a project's tab" msgstr "No puedes cambiar el tipo de una pestaña de proyecto" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "título" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "objetivo principal" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "estado de vida" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "categorías" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "objetivos de desarrollo sostenible" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 914a2f62..57090846 100644 --- a/locale/et/LC_MESSAGES/django.po +++ b/locale/et/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "Sa ei saa seda rolli kasutajale määrata" msgid "You cannot assign this role to a user : {role}" msgstr "Sa ei saa seda rolli kasutajale määrata: {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "nähtavus" @@ -1546,23 +1546,23 @@ msgstr "Sõnum ei saa olla vastus iseendale" msgid "You cannot change the type of a project's tab" msgstr "Sa ei saa muuta projekti vahelehe tüüpi" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "pealkiri" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "peamine eesmärk" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "elutsükli olek" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "kategooriad" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "säästva arengu eesmärgid" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index a15aa7f7..2e5ce9d0 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice" msgid "You cannot assign this role to a user : {role}" msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice : {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "visibilité" @@ -1563,23 +1563,23 @@ msgstr "Un message ne peut pas être une réponse à lui-même" msgid "You cannot change the type of a project's tab" msgstr "Vous ne pouvez pas changer le type d'un onglet de projet" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "titre" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "objectif principal" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "état d'avancement" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "catégories" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "objectifs de développement durable" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 8311d449..4f13dcc9 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-24 20:34+0100\n" +"POT-Creation-Date: 2026-05-26 18:22+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Je kunt deze rol niet toewijzen aan een gebruiker" msgid "You cannot assign this role to a user : {role}" msgstr "Je kunt deze rol niet toewijzen aan een gebruiker: {role}" -#: apps/accounts/models.py:161 apps/projects/models.py:164 +#: apps/accounts/models.py:161 apps/projects/models.py:169 msgid "visibility" msgstr "zichtbaarheid" @@ -1578,23 +1578,23 @@ msgstr "Een bericht kan geen antwoord op zichzelf zijn" msgid "You cannot change the type of a project's tab" msgstr "Je kunt het type van een projecttabblad niet wijzigen" -#: apps/projects/models.py:147 +#: apps/projects/models.py:152 msgid "title" msgstr "titel" -#: apps/projects/models.py:157 +#: apps/projects/models.py:162 msgid "main goal" msgstr "hoofddoel" -#: apps/projects/models.py:170 +#: apps/projects/models.py:175 msgid "life status" msgstr "levensstatus" -#: apps/projects/models.py:181 +#: apps/projects/models.py:186 msgid "categories" msgstr "categorieën" -#: apps/projects/models.py:198 +#: apps/projects/models.py:203 msgid "sustainable development goals" msgstr "duurzame ontwikkelingsdoelen" From 071402d5555d68ad1cef0527d2a4b2b6ceee4341 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 11:29:47 +0200 Subject: [PATCH 17/24] Revert "i18n" This reverts commit 1311e287bfeb2241d4a9882010536fe8123c773c. --- apps/accounts/models.py | 2 + .../accounts/tests/views/test_people_group.py | 4 +- apps/commons/mixins.py | 11 +- apps/modules/serializers.py | 4 +- .../tasks/test_add_members_notifications.py | 4 +- .../test_remove_members_notifications.py | 4 +- .../test_update_members_notifications.py | 2 +- .../tests/views/test_locked_project.py | 4 +- apps/projects/tests/views/test_project.py | 160 ++++++++---------- .../tests/views/test_project_history.py | 4 +- apps/projects/urls.py | 4 +- .../signals/test_project_index_signals.py | 4 +- .../tests/signals/test_user_index_signals.py | 4 +- 13 files changed, 97 insertions(+), 114 deletions(-) diff --git a/apps/accounts/models.py b/apps/accounts/models.py index fe1d4596..ef05894c 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -985,6 +985,8 @@ def has_perm(self, perm, obj=None): class InternalAdmin(AnonymousUser): + """internal admim user (he have access to all models)""" + def get_project_queryset(self): return Project.objects.all() diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index d54ca662..0d7b5333 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -749,7 +749,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 +757,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() diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 70880e87..e4c65ecf 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -369,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( @@ -458,14 +458,19 @@ 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() - modules_manager = self.get_related_module() - return modules_manager(self, internaladmin) + return self.modules_by_user(internaladmin) class HasEmbedding: diff --git a/apps/modules/serializers.py b/apps/modules/serializers.py index 2174f19c..a595ff9c 100644 --- a/apps/modules/serializers.py +++ b/apps/modules/serializers.py @@ -22,7 +22,6 @@ def __modules_keys(self): # if modules is not set, get "default" values from Meta serializer if modules_keys is None: modules_keys = getattr(self.Meta, "modules_keys", None) - print(modules_keys, type(self)) self.context["modules_keys"] = ( tuple(modules_keys) if modules_keys is not None else None ) @@ -31,5 +30,4 @@ def __modules_keys(self): def get_modules(self, instance): request = self.context.get("request") - modules_manager = instance.get_related_module() - return modules_manager(instance, user=request.user).count(self.__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/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..612544ba 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() @@ -85,19 +92,13 @@ def test_create_project(self, role, expected_code): "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 +121,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 +325,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 +377,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 +445,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 +467,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'', - duplicated_project["description"], + f'', + duplicated_project.description, ) self.assertEqual( - len(duplicated_project["blog_entries"]), - len(initial_project["blog_entries"]), + duplicated_project.modules.blogs().count(), + initial_project.modules.blogs().count(), ) - for duplicated_blog_entry in duplicated_project["blog_entries"]: - self.assertNotIn( - duplicated_blog_entry["id"], - [i["id"] for i in initial_project["blog_entries"]], - ) - initial_blog_entry = list( - filter(lambda x: len(x["images"]) > 0, initial_project["blog_entries"]) - )[0] - duplicated_blog_entry = list( - filter( - lambda x: len(x["images"]) > 0, - duplicated_project["blog_entries"], - ) - )[0] - for duplicated_image in duplicated_blog_entry["images"]: - self.assertNotIn(duplicated_image, initial_blog_entry["images"]) - self.assertIn( - f'', - duplicated_blog_entry["content"], - ) + + initial_blogs_ids = initial_project.modules.blogs().values_list("id", flat=True) + initial_blogs_images_ids = ( + initial_project.modules.blogs() + .values_list("images__id", flat=True) + .distinct() + ) + + for blog in duplicated_project.modules.blogs(): + self.assertNotIn(blog.id, initial_blogs_ids) + + duplicate_blogs_images_ids = blog.images.values_list( + "id", flat=True + ).distinct() + for image_id in duplicate_blogs_images_ids: + self.assertNotIn(image_id, initial_blogs_images_ids) + + self.assertIn( + f'', + blog.content, + ) + self.assertEqual( - Project.objects.get(id=duplicated_project["id"]).duplicated_from, - initial_project["id"], + duplicated_project.duplicated_from, + initial_project.id, ) @parameterized.expand( @@ -606,9 +584,9 @@ def test_duplicate_project(self, role, expected_code): reverse("Project-detail", args=(self.project.id,)) ) self.assertEqual(initial_response.status_code, status.HTTP_200_OK) - duplicated_project = response.json() - initial_project = initial_response.json() - self.check_duplicated_project(duplicated_project, initial_project) + duplicate = Project.objects.get(id=response.json()["id"]) + + self.check_duplicated_project(duplicate, self.project) class LockUnlockProjectTestCase(JwtAPITestCase): @@ -976,7 +954,7 @@ def test_remove_last_member(self): owner = project.owners.first() payload = {"users": [owner.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_400_BAD_REQUEST, response.content @@ -1147,7 +1125,7 @@ def test_change_member_role(self): user = UserFactory(groups=[project.get_members()]) payload = {GroupData.Role.OWNERS: [user.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) self.assertIn(user, project.owners.all()) @@ -1207,7 +1185,7 @@ def test_add_reviewer_to_public_project(self): reviewer = UserFactory() payload = {GroupData.Role.REVIEWERS: [reviewer.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) project.refresh_from_db() @@ -1223,7 +1201,7 @@ def test_add_reviewer_to_reviewed_public_project(self): reviewer = UserFactory() payload = {GroupData.Role.REVIEWERS: [reviewer.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) project.refresh_from_db() diff --git a/apps/projects/tests/views/test_project_history.py b/apps/projects/tests/views/test_project_history.py index a72e858c..ae21636f 100644 --- a/apps/projects/tests/views/test_project_history.py +++ b/apps/projects/tests/views/test_project_history.py @@ -73,7 +73,7 @@ def test_add_project_member(self): ) payload = {GroupData.Role.MEMBERS: [self.user.id]} self.client.post( - reverse("Project-add-member", args=(project.id,)), data=payload + reverse("Project-member-add-member", args=(project.id,)), data=payload ) history = HistoricalProject.objects.filter(history_relation__id=project.id) latest_version = history.order_by("-history_date").first() @@ -103,7 +103,7 @@ def test_remove_project_member(self): project.members.add(self.user) payload = {"users": [self.user.id]} self.client.post( - reverse("Project-remove-member", args=(project.id,)), data=payload + reverse("Project-member-remove-member", args=(project.id,)), data=payload ) history = HistoricalProject.objects.filter(history_relation__id=project.id) latest_version = history.order_by("-history_date").first() diff --git a/apps/projects/urls.py b/apps/projects/urls.py index a6ec35fb..dfa1ef60 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -44,10 +44,10 @@ router, r"history", HistoricalProjectViewSet, basename="Project-versions" ) project_router_register( - router, r"member", ProjectMembersViewSet, basename="Project-members" + router, r"member", ProjectMembersViewSet, basename="Project-member" ) project_router_register( - router, r"group", ProjectGroupsViewSet, basename="Project-groups" + router, r"group", ProjectGroupsViewSet, basename="Project-group" ) project_router_register(router, r"blog-entry", BlogEntryViewSet, basename="BlogEntry") project_router_register( diff --git a/apps/search/tests/signals/test_project_index_signals.py b/apps/search/tests/signals/test_project_index_signals.py index 487693a3..0a473d58 100644 --- a/apps/search/tests/signals/test_project_index_signals.py +++ b/apps/search/tests/signals/test_project_index_signals.py @@ -84,7 +84,7 @@ def test_signal_called_on_add_members(self, mocked_update): self.client.force_authenticate(self.superadmin) payload = {"members": [self.member_to_add.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) mocked_update.assert_has_calls([call(self.project, "index")]) @@ -97,7 +97,7 @@ def test_signal_called_on_remove_members(self, mocked_update): self.client.force_authenticate(self.superadmin) payload = {"users": [self.member_to_remove.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) mocked_update.assert_has_calls([call(self.project, "index")]) diff --git a/apps/search/tests/signals/test_user_index_signals.py b/apps/search/tests/signals/test_user_index_signals.py index aa2781d8..45be6813 100644 --- a/apps/search/tests/signals/test_user_index_signals.py +++ b/apps/search/tests/signals/test_user_index_signals.py @@ -155,7 +155,7 @@ def test_signal_called_on_add_project_member(self, mocked_update): self.client.force_authenticate(self.superadmin) payload = {GroupData.Role.MEMBERS: [self.user.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) mocked_update.assert_has_calls([call(self.user, "index")]) @@ -168,7 +168,7 @@ def test_signal_called_on_remove_project_member(self, mocked_update): self.client.force_authenticate(self.superadmin) payload = {"users": [self.project_remove_member.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) mocked_update.assert_has_calls([call(self.project_remove_member, "index")]) From 059bd293761c54cf64718b015b1f82f9109e9917 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 12:08:25 +0200 Subject: [PATCH 18/24] cleanuo --- apps/announcements/views.py | 1 + apps/projects/models.py | 12 ++++---- apps/projects/views.py | 58 +++++++++++++++---------------------- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index 84e307ae..f8553768 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -32,6 +32,7 @@ class AnnouncementViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): lookup_value_regex = "[0-9]+" filter_backends = [DjangoFilterBackend, OrderingFilter] ordering_fields = ["updated_at", "created_at", "deadline"] + ordering = ["updated_at"] permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, diff --git a/apps/projects/models.py b/apps/projects/models.py index 563c9fd3..e0ab3d80 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -49,22 +49,22 @@ def uuid_generator() -> str: return shortuuid.ShortUUID().random(length=8) -class SoftDeleteManager(MultipleIdsQuerySet): +class SoftDeleteManager(models.manager.BaseManager.from_queryset(MultipleIdsQuerySet)): """Exclude by default soft-deleted Projects.""" def get_queryset(self): """Exclude by default soft-deleted Projects.""" - return self.filter(deleted_at=None) + return super().get_queryset().filter(deleted_at=None) def all_with_delete(self, pk=None): """Retrieve all projects, or the one corresponding to `pk` if given.""" if pk is None: - return self.get_queryset() - return self.get_queryset().get(pk=pk) + return super().get_queryset() + return super().get_queryset().get(pk=pk) def deleted_projects(self): """Retrieve all soft-deleted projects.""" - return self.get_queryset().exclude(deleted_at=None) + return super().get_queryset().exclude(deleted_at=None) class Project( @@ -222,7 +222,7 @@ class LifeStatus(models.TextChoices): max_length=8, null=True, blank=True, default=None ) permissions_up_to_date = models.BooleanField(default=False) - objects = SoftDeleteManager.as_manager() + objects = SoftDeleteManager() class Meta: write_only_subscopes = ( diff --git a/apps/projects/views.py b/apps/projects/views.py index a2a92022..5aeb0b07 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -296,10 +296,11 @@ def similar(self, request, *args, **kwargs): raise OrganizationsParameterMissing project = self.get_object() - modules_manager = project.get_related_module() - modules = modules_manager(project, request.user) - queryset = modules.similars().filter( - organizations__code__in=get_below_hierarchy_codes(organizations) + + queryset = ( + project.mobules_by_user(request.user) + .similars() + .filter(organizations__code__in=get_below_hierarchy_codes(organizations)) ) page = self.paginate_queryset(queryset) @@ -400,9 +401,7 @@ class ProjectMembersViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectUser]: - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - return modules.members() + return self.project.modules_by_user(self.request.user).members() @extend_schema(request=ProjectAddTeamMembersSerializer, responses=ProjectSerializer) @action( @@ -519,9 +518,11 @@ class ProjectGroupsViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - return modules.groups().select_related("organization") + return ( + self.project.modules_by_user(self.request.user) + .groups() + .select_related("organization") + ) class BlogEntryViewSet( @@ -543,10 +544,11 @@ class BlogEntryViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - - return modules.blogs().prefetch_related("images") + return ( + self.project.modules_by_user(self.request.user) + .blogs() + .prefetch_related("images") + ) def perform_create(self, serializer): instance = serializer.save() @@ -568,10 +570,9 @@ class BlogEntryImagesView( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) + blogs_qs = self.project.modules_by_user(self.request.user).blogs() - qs = Image.objects.filter(pk__in=modules.blog()) + qs = Image.objects.filter(pk__in=blogs_qs) # Retrieve images before blog entry is posted if self.request.user.is_authenticated: qs = qs | Image.objects.filter(owner=self.request.user) @@ -618,10 +619,7 @@ class GoalViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - - return modules.goals() + return self.project.modules_by_user(self.request.user).goals() class LocationViewSet( @@ -642,10 +640,7 @@ class LocationViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - - return modules.locations() + return self.project.modules_by_user(self.request.user).locations() @method_decorator( redis_cache_view("locations_list_cache", settings.CACHE_LOCATIONS_LIST_TTL) @@ -697,10 +692,7 @@ class LinkedProjectViewSet( multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - - return modules.linked_projects() + return self.project.modules_by_user(self.request.user).linked_projects() def check_linked_project_permission(self, project): if not self.request.user.can_see_project(project): @@ -811,9 +803,7 @@ def get_permissions(self): def get_queryset(self): # get_project_related_queryset is not needed because the publication_status is not checked here - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - queryset = modules.messages() + queryset = self.project.modules_by_user(self.request.user).messages() if self.action in ["retrieve", "list"]: queryset = queryset.exclude(reply_on__isnull=False) @@ -848,9 +838,7 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): - modules_manager = self.project.get_related_module() - modules = modules_manager(self.project, self.request.user) - queryset = modules.messages() + queryset = self.project.modules_by_user(self.request.user).messages() if "project_id" in self.kwargs: qs = Image.objects.filter(project_messages__in=queryset) From d1730c7f241fb2c94421c517a40cfe9261054cee Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 13:36:17 +0200 Subject: [PATCH 19/24] cleanup tests --- apps/accounts/permissions.py | 6 +++ apps/commons/test.py | 3 +- apps/commons/views.py | 10 +++- apps/projects/tests/views/test_blog_entry.py | 10 ++-- .../tests/views/test_blog_entry_images.py | 5 +- apps/projects/tests/views/test_goal.py | 10 ++-- .../tests/views/test_linked_project.py | 47 +++++++++---------- apps/projects/tests/views/test_location.py | 9 ++-- apps/projects/views.py | 6 +-- 9 files changed, 67 insertions(+), 39 deletions(-) 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/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/views.py b/apps/commons/views.py index e384c2dc..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 @@ -157,13 +159,19 @@ def initial(self, request, *args, **kwargs): class NestedProjectViewMixins: + project: Project + def initial(self, request, *args, **kwargs): self.project = get_object_or_404( - request.user.get_project_queryset().slug_or_id(kwargs["project_id"]), + 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): 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..2cd762fb 100644 --- a/apps/projects/tests/views/test_blog_entry_images.py +++ b/apps/projects/tests/views/test_blog_entry_images.py @@ -70,7 +70,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/views.py b/apps/projects/views.py index 5aeb0b07..bf9b9a97 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -572,7 +572,7 @@ class BlogEntryImagesView( def get_queryset(self): blogs_qs = self.project.modules_by_user(self.request.user).blogs() - qs = Image.objects.filter(pk__in=blogs_qs) + qs = Image.objects.filter(blog_entries__in=blogs_qs) # Retrieve images before blog entry is posted if self.request.user.is_authenticated: qs = qs | Image.objects.filter(owner=self.request.user) @@ -838,10 +838,10 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): - queryset = self.project.modules_by_user(self.request.user).messages() + messages_qs = self.project.modules_by_user(self.request.user).messages() if "project_id" in self.kwargs: - qs = Image.objects.filter(project_messages__in=queryset) + qs = Image.objects.filter(project_messages__in=messages_qs) # Retrieve images before message is posted if self.request.user.is_authenticated: qs = qs | Image.objects.filter(owner=self.request.user) From 896656dfa5ef7d3eb0b15d0c92dd9d706c16f971 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 14:24:49 +0200 Subject: [PATCH 20/24] cleanup tests --- apps/projects/permissions.py | 12 +++--------- .../projects/tests/views/test_project_history.py | 16 +++++++--------- apps/projects/urls.py | 10 ++++------ apps/projects/views.py | 5 +++-- 4 files changed, 17 insertions(+), 26 deletions(-) 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/tests/views/test_project_history.py b/apps/projects/tests/views/test_project_history.py index ae21636f..6edf70be 100644 --- a/apps/projects/tests/views/test_project_history.py +++ b/apps/projects/tests/views/test_project_history.py @@ -332,15 +332,13 @@ def test_add_many_linked_project(self): .exclude(history_change_reason=None) .count() ) - payload = { - "projects": [ - { - "project_id": to_link.id, - "reason": faker.sentence(), - "target_id": project.id, - } - ] - } + payload = [ + { + "project_id": to_link.id, + "reason": faker.sentence(), + "target_id": project.id, + } + ] self.client.post( reverse("LinkedProjects-add-many", args=(project.id,)), data=payload ) diff --git a/apps/projects/urls.py b/apps/projects/urls.py index dfa1ef60..4dc39ea1 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -20,10 +20,10 @@ HistoricalProjectViewSet, LinkedProjectViewSet, LocationViewSet, - ProjectGroupsViewSet, + ProjectGroupViewSet, ProjectHeaderView, ProjectImagesView, - ProjectMembersViewSet, + ProjectMemberViewSet, ProjectMessageImagesView, ProjectMessageViewSet, ProjectTabImagesView, @@ -44,11 +44,9 @@ router, r"history", HistoricalProjectViewSet, basename="Project-versions" ) project_router_register( - router, r"member", ProjectMembersViewSet, basename="Project-member" -) -project_router_register( - router, r"group", ProjectGroupsViewSet, basename="Project-group" + router, r"member", ProjectMemberViewSet, basename="Project-member" ) +project_router_register(router, r"group", ProjectGroupViewSet, basename="Project-group") project_router_register(router, r"blog-entry", BlogEntryViewSet, basename="BlogEntry") project_router_register( router, diff --git a/apps/projects/views.py b/apps/projects/views.py index bf9b9a97..07e6300f 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -379,7 +379,7 @@ def add_image_to_model(self, image, *args, **kwargs): return None -class ProjectMembersViewSet( +class ProjectMemberViewSet( NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet, @@ -410,6 +410,7 @@ def get_queryset(self) -> QuerySet[ProjectUser]: url_path="add", permission_classes=[ IsAuthenticated, + ProjectIsNotLocked, HasBasePermission("change_project", "projects") | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), @@ -496,7 +497,7 @@ def notify_remove_members(self, instances): ) -class ProjectGroupsViewSet( +class ProjectGroupViewSet( NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet, From ed5876229875973dc7e87d8aaf60219348c7af9e Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 15:06:54 +0200 Subject: [PATCH 21/24] cleanup tests --- .../accounts/tests/views/test_people_group.py | 90 +++++++++---------- .../views/test_user_publication_status.py | 33 ++++--- apps/commons/tests/test_process_text.py | 31 +++---- apps/projects/tests/views/test_project.py | 1 - .../signals/test_project_index_signals.py | 1 - 5 files changed, 80 insertions(+), 76 deletions(-) diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 0d7b5333..d14e08eb 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 = [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,22 @@ 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-member", args=(organization.code, response.data["id"]) + ), + payload=team, + ) + self.assertEqual(rsp2.status_code, status.HTTP_201_CREATED) + rsp3 = self.client.post( + reverse( + "PeopleGroup-project", args=(organization.code, response.data["id"]) + ), + featured_projects, + ) + self.assertEqual(rsp3.status_code, status.HTTP_201_CREATED) + people_group = PeopleGroup.objects.get(id=response.json()["id"]) for member in members: self.assertIn(member, people_group.members.all()) @@ -1039,19 +1056,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,41 +1083,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["role"] == GroupData.Role.MANAGERS for user in batch_1) - ) - self.assertTrue(all(user["role"] == GroupData.Role.LEADERS 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["role"] == GroupData.Role.MANAGERS for user in batch_2) - ) - self.assertTrue(all(user["role"] == GroupData.Role.LEADERS 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["role"] == GroupData.Role.MANAGERS for user in batch_3) - ) - self.assertTrue(all(user["role"] != GroupData.Role.LEADERS 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["role"] == GroupData.Role.MANAGERS for user in batch_4) - ) - self.assertTrue(all(user["role"] != GroupData.Role.LEADERS 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/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/projects/tests/views/test_project.py b/apps/projects/tests/views/test_project.py index 612544ba..3bfd6309 100644 --- a/apps/projects/tests/views/test_project.py +++ b/apps/projects/tests/views/test_project.py @@ -91,7 +91,6 @@ 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": [], } response = self.client.post(reverse("Project-list"), data=payload) self.assertEqual(response.status_code, expected_code) diff --git a/apps/search/tests/signals/test_project_index_signals.py b/apps/search/tests/signals/test_project_index_signals.py index 0a473d58..b02ec161 100644 --- a/apps/search/tests/signals/test_project_index_signals.py +++ b/apps/search/tests/signals/test_project_index_signals.py @@ -55,7 +55,6 @@ def test_signal_called_on_project_create(self, mocked_update): "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) From 2e6be26676b6281aa3796c122816e4f5632758b9 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 15:34:06 +0200 Subject: [PATCH 22/24] cleanup tests --- apps/accounts/tests/views/test_people_group.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index d14e08eb..904e7abe 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -327,7 +327,7 @@ def test_create_people_group(self, role, expected_code): "managers": [r.id for r in managers], "leaders": [r.id for r in leaders], } - featured_projects = [p.pk for p in projects] + featured_projects = {"featured_projects": [p.pk for p in projects]} self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_201_CREATED: @@ -340,18 +340,20 @@ def test_create_people_group(self, role, expected_code): rsp2 = self.client.post( reverse( - "PeopleGroup-member", args=(organization.code, response.data["id"]) + "PeopleGroup-add-member", + args=(organization.code, response.data["id"]), ), - payload=team, + team, ) - self.assertEqual(rsp2.status_code, status.HTTP_201_CREATED) + self.assertEqual(rsp2.status_code, status.HTTP_204_NO_CONTENT) rsp3 = self.client.post( reverse( - "PeopleGroup-project", args=(organization.code, response.data["id"]) + "PeopleGroup-add-featured-project", + args=(organization.code, response.data["id"]), ), featured_projects, ) - self.assertEqual(rsp3.status_code, status.HTTP_201_CREATED) + self.assertEqual(rsp3.status_code, status.HTTP_204_NO_CONTENT) people_group = PeopleGroup.objects.get(id=response.json()["id"]) for member in members: From 875d5f7e3ab4e4a906c3d34be52438a735365be5 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 29 May 2026 15:58:47 +0200 Subject: [PATCH 23/24] fix: typos --- apps/projects/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/projects/views.py b/apps/projects/views.py index 07e6300f..960389d8 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -298,7 +298,7 @@ def similar(self, request, *args, **kwargs): project = self.get_object() queryset = ( - project.mobules_by_user(request.user) + project.modules_by_user(request.user) .similars() .filter(organizations__code__in=get_below_hierarchy_codes(organizations)) ) From 0093b75ed98a0832983e3502a6b14a3ca3a6b39d Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 1 Jun 2026 11:42:08 +0200 Subject: [PATCH 24/24] test: fix errors --- apps/projects/serializers.py | 12 +++++++++++- apps/projects/tests/views/test_blog_entry_images.py | 1 - apps/projects/tests/views/test_project_history.py | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index fd0aed63..223c50de 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -504,7 +504,17 @@ def create(self, validate_data: dict) -> LinkedProject: return linked def update(self, instance, validate_data: dict) -> LinkedProject: - return instance + 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) + + return linked class UserLighterSerializerKeycloakRelatedField( diff --git a/apps/projects/tests/views/test_blog_entry_images.py b/apps/projects/tests/views/test_blog_entry_images.py index 2cd762fb..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")), diff --git a/apps/projects/tests/views/test_project_history.py b/apps/projects/tests/views/test_project_history.py index 6edf70be..93bddb1c 100644 --- a/apps/projects/tests/views/test_project_history.py +++ b/apps/projects/tests/views/test_project_history.py @@ -300,7 +300,9 @@ def test_update_linked_project(self): .exclude(history_change_reason=None) .count() ) - payload = {"reason": faker.sentence()} + payload = { + "project_id": ProjectFactory(organizations=[self.organization]).id, + } self.client.patch( reverse("LinkedProjects-detail", args=(project.id, linked_project.id)), data=payload,