From 169c532b60e799c5ded81bcb1f7fd20be95fadec Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 17 Apr 2026 16:15:25 +0200 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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, From aff175f37bd67d781bb93f1b25ee20e7e3ea7176 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 10:46:35 +0200 Subject: [PATCH 25/42] test: fix peoplegroup --- services/google/tests/test_tasks.py | 36 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/services/google/tests/test_tasks.py b/services/google/tests/test_tasks.py index 80f691a9..f97e6087 100644 --- a/services/google/tests/test_tasks.py +++ b/services/google/tests/test_tasks.py @@ -240,8 +240,9 @@ def test_create_google_group_with_email(self, mocked, mocked_delay): "create_in_google": True, "email": f"googlesync-{uuid.uuid4()}@{settings.GOOGLE_EMAIL_DOMAIN}", "description": "", - "team": {"members": [google_user.user.id]}, } + payload_team = ({"members": [google_user.user.id]},) + mocked.side_effect = self.google_side_effect( [ self.get_google_group_error(), # group email is available @@ -278,9 +279,19 @@ def test_create_google_group_with_email(self, mocked, mocked_delay): reverse("PeopleGroup-list", args=(self.organization.code,)), data=payload, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - content = response.json() - people_group = PeopleGroup.objects.get(id=content["id"]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + content = response.json() + people_group = PeopleGroup.objects.get(id=content["id"]) + + response_team = self.client.post( + reverse( + "PeopleGroup-add-member", + args=(self.organization.code, people_group.id), + ), + data=payload_team, + ) + self.assertEqual(response_team.status_code, status.HTTP_201_CREATED) + self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) mocked_add_group_alias.assert_called_once_with(people_group.google_group) @@ -369,8 +380,8 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): "organization": self.organization.code, "create_in_google": True, "description": "", - "team": {"members": [google_user.user.id]}, } + payload_team = ({"members": [google_user.user.id]},) mocked.side_effect = self.google_side_effect( [ self.get_google_group_success(), # group email is taken @@ -409,9 +420,18 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): reverse("PeopleGroup-list", args=(self.organization.code,)), data=payload, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - content = response.json() - people_group = PeopleGroup.objects.get(id=content["id"]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + content = response.json() + people_group = PeopleGroup.objects.get(id=content["id"]) + + response_team = self.client.post( + reverse( + "PeopleGroup-list", args=(self.organization.code, people_group.id) + ), + data=payload_team, + ) + self.assertEqual(response_team.status_code, status.HTTP_201_CREATED) + self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) mocked_add_group_alias.assert_called_once_with(people_group.google_group) From 3e4f3b33a93590d982cdb0d09494d988c4bf3081 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 11:30:52 +0200 Subject: [PATCH 26/42] test: fix tests --- services/google/tests/test_tasks.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/google/tests/test_tasks.py b/services/google/tests/test_tasks.py index f97e6087..7c8bddec 100644 --- a/services/google/tests/test_tasks.py +++ b/services/google/tests/test_tasks.py @@ -241,7 +241,7 @@ def test_create_google_group_with_email(self, mocked, mocked_delay): "email": f"googlesync-{uuid.uuid4()}@{settings.GOOGLE_EMAIL_DOMAIN}", "description": "", } - payload_team = ({"members": [google_user.user.id]},) + payload_team = {"members": [google_user.user.id]} mocked.side_effect = self.google_side_effect( [ @@ -381,7 +381,7 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): "create_in_google": True, "description": "", } - payload_team = ({"members": [google_user.user.id]},) + payload_team = {"members": [google_user.user.id]} mocked.side_effect = self.google_side_effect( [ self.get_google_group_success(), # group email is taken @@ -426,7 +426,8 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): response_team = self.client.post( reverse( - "PeopleGroup-list", args=(self.organization.code, people_group.id) + "PeopleGroup-add-member", + args=(self.organization.code, people_group.id), ), data=payload_team, ) From bdb52b371c165d2d0d4976462fd8957000b85afc Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 12:44:00 +0200 Subject: [PATCH 27/42] test: cleanup mixins --- apps/announcements/urls.py | 2 +- apps/announcements/views.py | 44 ++-- apps/commons/views.py | 13 +- apps/feedbacks/views.py | 56 ++--- apps/files/urls.py | 8 +- apps/files/views.py | 25 +-- apps/organizations/urls.py | 5 + apps/organizations/views.py | 55 ++++- .../tests/views/test_project_images.py | 9 +- apps/projects/urls.py | 13 +- apps/projects/views.py | 194 ++++-------------- 11 files changed, 161 insertions(+), 263 deletions(-) diff --git a/apps/announcements/urls.py b/apps/announcements/urls.py index 69326246..4751caf0 100644 --- a/apps/announcements/urls.py +++ b/apps/announcements/urls.py @@ -4,5 +4,5 @@ router = DefaultRouter() router.register( - r"announcement", views.ReadAnnouncementViewSet, basename="Read-announcement" + r"announcement", views.AnnouncementViewSet, basename="Read-announcement" ) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index f8553768..e774013f 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -1,5 +1,5 @@ -from django.conf import settings from django.utils.decorators import method_decorator +from django.views import View from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -9,15 +9,14 @@ from rest_framework.response import Response from apps.accounts.permissions import HasBasePermission -from apps.commons.cache import clear_cache_with_key, redis_cache_view +from apps.commons.cache import clear_cache_with_key from apps.commons.permissions import ReadOnly -from apps.commons.views import MultipleIDViewsetMixin +from apps.commons.views import NestedProjectViewMixins from apps.notifications.tasks import ( notify_new_announcement, notify_new_application, ) from apps.organizations.permissions import HasOrganizationPermission -from apps.projects.models import Project from apps.projects.permissions import HasProjectPermission, ProjectIsNotLocked from .filters import AnnouncementFilter @@ -25,14 +24,24 @@ from .serializers import AnnouncementSerializer, ApplyToAnnouncementSerializer -class AnnouncementViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class AnnouncementViewSet(viewsets.ModelViewSet): serializer_class = AnnouncementSerializer filterset_class = AnnouncementFilter lookup_field = "id" lookup_value_regex = "[0-9]+" filter_backends = [DjangoFilterBackend, OrderingFilter] - ordering_fields = ["updated_at", "created_at", "deadline"] - ordering = ["updated_at"] + ordering_fields = ("updated_at", "created_at", "deadline") + ordering = ("updated_at",) + # viewset only get/set lements + http_method_names = ["get", "list"] + + def get_queryset(self): + return self.request.user.get_project_related_queryset( + Announcement.objects.filter(project__deleted_at__isnull=True) + ).distinct() + + +class ProjectAnnouncementViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -41,15 +50,10 @@ class AnnouncementViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] + http_method_names = View.http_method_names def get_queryset(self): - qs = self.request.user.get_project_related_queryset( - Announcement.objects.filter(project__deleted_at__isnull=True) - ) - if "project_id" in self.kwargs: - qs = qs.filter(project=self.kwargs["project_id"]) - return qs.select_related("project__header_image").distinct() + return self.project.modules_by_user(self.request.user).announcements() def perform_create(self, serializer): announcement = serializer.save() @@ -74,15 +78,3 @@ def apply(self, request, **kwargs): @method_decorator(clear_cache_with_key("announcements_list_cache")) def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) - - -class ReadAnnouncementViewSet(AnnouncementViewSet): - http_method_names = ["get", "list"] - - @method_decorator( - redis_cache_view( - "announcements_list_cache", settings.CACHE_ANNOUNCEMENTS_LIST_TTL - ) - ) - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) diff --git a/apps/commons/views.py b/apps/commons/views.py index aff13401..d1ff0ca8 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -1,3 +1,6 @@ +from functools import cached_property + +from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from rest_framework import mixins, viewsets from rest_framework.response import Response @@ -5,6 +8,7 @@ from apps.accounts.permissions import ProjectNestedPermission from apps.organizations.models import Organization +from apps.organizations.utils import get_below_hierarchy_codes from apps.projects.models import Project from .mixins import HasMultipleIDs @@ -157,8 +161,15 @@ def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) + @cached_property + def organizations(self) -> QuerySet[Organization]: + """get all organizations""" + organizations_code = get_below_hierarchy_codes((self.organization.code,)) + return Organization.objects.filter(code__in=organizations_code) + -class NestedProjectViewMixins: +class NestedProjectViewMixins(MultipleIDViewsetMixin): + multiple_lookup_fields = [(Project, "project_id")] project: Project def initial(self, request, *args, **kwargs): diff --git a/apps/feedbacks/views.py b/apps/feedbacks/views.py index 6505f6dc..7c1de37c 100644 --- a/apps/feedbacks/views.py +++ b/apps/feedbacks/views.py @@ -2,7 +2,7 @@ from django.db import transaction from django.db.models import Q, QuerySet -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -15,7 +15,11 @@ from apps.accounts.permissions import HasBasePermission from apps.commons.permissions import IsOwner, ReadOnly from apps.commons.utils import map_action_to_permission -from apps.commons.views import CreateListDestroyViewSet, MultipleIDViewsetMixin +from apps.commons.views import ( + CreateListDestroyViewSet, + MultipleIDViewsetMixin, + NestedProjectViewMixins, +) from apps.feedbacks.exceptions import FollowProjectPermissionDeniedError from apps.files.models import Image from apps.files.views import ImageStorageView @@ -154,14 +158,13 @@ class ProjectFollowViewSet(FollowViewSet): pass -class CommentViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class CommentViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = CommentSerializer 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")] def get_permissions(self): codename = map_action_to_permission(self.action, "comment") @@ -176,12 +179,9 @@ def get_permissions(self): ] return super().get_permissions() - def get_queryset(self) -> QuerySet: - qs = self.request.user.get_project_related_queryset(Comment.objects.all()) - if self.request.user.is_authenticated: - qs = (qs | Comment.objects.filter(author=self.request.user)).distinct() - if "project_id" in self.kwargs: - qs = qs.filter(project=self.kwargs["project_id"]) + def get_queryset(self) -> QuerySet[Comment]: + qs = self.project.modules_by_user(self.request.user).comment() + if self.action in ["retrieve", "list"]: qs = qs.exclude( Q(reply_on__isnull=False) @@ -191,13 +191,6 @@ def get_queryset(self) -> QuerySet: "replies", "images" ) - def create(self, request, *args, **kwargs): - get_object_or_404( - self.request.user.get_project_queryset(), - id=self.kwargs["project_id"], - ) - return super().create(request, *args, **kwargs) - @transaction.atomic def perform_create(self, serializer): comment = serializer.save(author=self.request.user) @@ -208,9 +201,7 @@ def perform_destroy(self, instance: Comment): instance.soft_delete(self.request.user) -class CommentImagesView(MultipleIDViewsetMixin, ImageStorageView): - multiple_lookup_fields = [(Project, "project_id")] - +class CommentImagesView(NestedProjectViewMixins, ImageStorageView): def get_permissions(self): """ Permissions are handled differently here because contrary to other @@ -230,23 +221,10 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): - if "project_id" in self.kwargs: - qs = self.request.user.get_project_related_queryset( - Image.objects.filter(comments__project=self.kwargs["project_id"]), - project_related_name="comments__project", - ) - # Retrieve images before comment is posted - if self.request.user.is_authenticated: - qs = qs | Image.objects.filter(owner=self.request.user) - return qs.distinct() - return Image.objects.none() - - def create(self, request, *args, **kwargs): - get_object_or_404( - self.request.user.get_project_queryset(), - id=self.kwargs["project_id"], - ) - return super().create(request, *args, **kwargs) + qs = self.project.modules_by_user(self.request.user).comment() + 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: @@ -257,6 +235,4 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - return f"/v1/project/{self.kwargs['project_id']}/comment-image/{image.id}" - return None + return f"/v1/project/{self.project.id}/comment-image/{image.id}" diff --git a/apps/files/urls.py b/apps/files/urls.py index 76cbda0c..6a82667a 100644 --- a/apps/files/urls.py +++ b/apps/files/urls.py @@ -7,10 +7,10 @@ user_router_register, ) from apps.files.views import ( - AttachmentFileViewSet, - AttachmentLinkViewSet, OrganizationAttachmentFileViewSet, PeopleGroupGalleryViewSet, + ProjectAttachmentFileViewSet, + ProjectAttachmentLinkViewSet, ProjectUserAttachmentFileViewSet, ProjectUserAttachmentLinkViewSet, ) @@ -24,10 +24,10 @@ basename="OrganizationAttachmentFile", ) project_router_register( - router, r"file", AttachmentFileViewSet, basename="AttachmentFile" + router, r"file", ProjectAttachmentFileViewSet, basename="AttachmentFile" ) project_router_register( - router, r"link", AttachmentLinkViewSet, basename="AttachmentLink" + router, r"link", ProjectAttachmentLinkViewSet, basename="AttachmentLink" ) diff --git a/apps/files/views.py b/apps/files/views.py index 33fc7be5..80c04de1 100644 --- a/apps/files/views.py +++ b/apps/files/views.py @@ -24,19 +24,16 @@ from apps.commons.permissions import IsOwner, ReadOnly, WillBeOwner from apps.commons.utils import map_action_to_permission from apps.commons.views import ( - MultipleIDViewsetMixin, NestedOrganizationViewMixins, NestedPeopleGroupViewMixins, + NestedProjectViewMixins, ) from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission -from apps.projects.models import Project from apps.projects.permissions import HasProjectPermission, ProjectIsNotLocked from .exceptions import ProtectedImageError from .models import ( - AttachmentFile, - AttachmentLink, Image, OrganizationAttachmentFile, ProjectUserAttachmentFile, @@ -53,7 +50,7 @@ ) -class AttachmentLinkViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectAttachmentLinkViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = AttachmentLinkSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -65,15 +62,9 @@ class AttachmentLinkViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - 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( - AttachmentLink.objects.all() - ) - return qs.filter(project=self.kwargs["project_id"]) - return AttachmentLink.objects.none() + return self.project.modules_by_user(self.request.user).links() class OrganizationAttachmentFileViewSet(viewsets.ModelViewSet): @@ -118,7 +109,7 @@ def retrieve(self, request, *args, **kwargs): return redirect(instance.file.url) -class AttachmentFileViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectAttachmentFileViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): parser_classes = [MultiPartParser] serializer_class = AttachmentFileSerializer filter_backends = [DjangoFilterBackend] @@ -132,15 +123,9 @@ class AttachmentFileViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - 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( - AttachmentFile.objects.all() - ) - return qs.filter(project=self.kwargs["project_id"]) - return AttachmentFile.objects.none() + return self.project.modules_by_user(self.request.user).files() def retrieve(self, request, *args, **kwargs): instance = self.get_object() diff --git a/apps/organizations/urls.py b/apps/organizations/urls.py index 626ec1ba..8db1266c 100644 --- a/apps/organizations/urls.py +++ b/apps/organizations/urls.py @@ -15,6 +15,7 @@ from .views import ( CategoryFollowViewset, + GeneralLocationView, OrganizationBannerView, OrganizationImagesView, OrganizationLogoView, @@ -82,3 +83,7 @@ PeopleGroupHeaderView, basename="PeopleGroup-header", ) + +organization_router_register( + router, r"location", GeneralLocationView, basename="General-location" +) diff --git a/apps/organizations/views.py b/apps/organizations/views.py index d654f567..a4f07424 100644 --- a/apps/organizations/views.py +++ b/apps/organizations/views.py @@ -16,21 +16,31 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.accounts.models import PeopleGroup, ProjectUser +from apps.accounts.models import PeopleGroup, PeopleGroupLocation, ProjectUser from apps.accounts.permissions import HasBasePermission from apps.accounts.serializers import ( PeopleGroupHierarchySerializer, + PeopleGroupLocationSuperLightSerializer, UserSerializer, ) from apps.commons.cache import clear_cache_with_key, redis_cache_view from apps.commons.permissions import IsOwner, ReadOnly, WillBeOwner from apps.commons.utils import map_action_to_permission -from apps.commons.views import CreateListDestroyViewSet, MultipleIDViewsetMixin +from apps.commons.views import ( + CreateListDestroyViewSet, + MultipleIDViewsetMixin, + NestedOrganizationViewMixins, +) from apps.files.models import Image from apps.files.views import ImageStorageView from apps.modules.group import PeopleGroupModules -from apps.projects.models import Project -from apps.projects.serializers import ProjectLightSerializer +from apps.newsfeed.models import EventLocation, NewsLocation +from apps.newsfeed.serializers import ( + EventLocationSerializerLight, + NewsLocationSerializerLight, +) +from apps.projects.models import Location, Project +from apps.projects.serializers import LocationSerializer, ProjectLightSerializer from .exceptions import ( MissingLifeStatusParameterError, @@ -306,11 +316,11 @@ def get_serializer_class(self): ) ) def list(self, request, *args, **kwargs): - return super(OrganizationViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) @method_decorator(clear_cache_with_key("organizations_list_cache")) def dispatch(self, request, *args, **kwargs): - return super(OrganizationViewSet, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) @extend_schema( request=OrganizationAddTeamMembersSerializer, responses=UserSerializer @@ -721,3 +731,36 @@ def get(self, request): [{"code": code, "name": name} for code, name in settings.LANGUAGES], status=status.HTTP_200_OK, ) + + +class GeneralLocationView(NestedOrganizationViewMixins, viewsets.GenericViewSet): + http_method_names = ["get", "list"] + + def list(self, request, *args, **kwargs): + qs_project = ( + request.user.get_project_related_queryset(Location.objects) + .select_related("project") + .filter(project__organizations__in=self.organizations) + ) + + qs_group = request.user.get_people_group_related_queryset( + PeopleGroupLocation.objects.filter( + people_group__organization__in=self.organizations + ) + ).select_related("people_group") + + qs_news = request.user.get_news_related_queryset( + NewsLocation.objects.filter(news__organization__in=self.organizations) + ).select_related("news") + + qs_event = request.user.get_event_related_queryset( + EventLocation.objects.filter(event__organization__in=self.organizations) + ).select_related("event") + + data = { + "groups": PeopleGroupLocationSuperLightSerializer(qs_group, many=True).data, + "projects": LocationSerializer(qs_project, many=True).data, + "news": NewsLocationSerializerLight(qs_news, many=True).data, + "event": EventLocationSerializerLight(qs_event, many=True).data, + } + return Response(data, status=status.HTTP_200_OK) diff --git a/apps/projects/tests/views/test_project_images.py b/apps/projects/tests/views/test_project_images.py index 01438192..d66180e4 100644 --- a/apps/projects/tests/views/test_project_images.py +++ b/apps/projects/tests/views/test_project_images.py @@ -45,7 +45,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")), @@ -68,7 +67,13 @@ def test_retrieve_project_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 CreateProjectImageTestCase(JwtAPITestCase): diff --git a/apps/projects/urls.py b/apps/projects/urls.py index 4dc39ea1..b6850f62 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -1,10 +1,7 @@ from rest_framework.routers import DefaultRouter -from apps.announcements.views import AnnouncementViewSet -from apps.commons.urls import ( - organization_router_register, - project_router_register, -) +from apps.announcements.views import ProjectAnnouncementViewSet +from apps.commons.urls import project_router_register from apps.feedbacks.views import ( CommentImagesView, CommentViewSet, @@ -15,7 +12,6 @@ from .views import ( BlogEntryImagesView, BlogEntryViewSet, - GeneralLocationView, GoalViewSet, HistoricalProjectViewSet, LinkedProjectViewSet, @@ -35,9 +31,6 @@ router = DefaultRouter() -organization_router_register( - router, r"location", GeneralLocationView, basename="General-location" -) router.register(r"project", ProjectViewSet, basename="Project") project_router_register( @@ -66,7 +59,7 @@ project_router_register(router, r"follow", ProjectFollowViewSet, basename="Followed") project_router_register(router, r"review", ReviewViewSet, basename="Reviewed") project_router_register( - router, r"announcement", AnnouncementViewSet, basename="Announcement" + router, r"announcement", ProjectAnnouncementViewSet, basename="Announcement" ) project_router_register(router, r"image", ProjectImagesView, basename="Project-images") project_router_register(router, r"header", ProjectHeaderView, basename="Project-header") diff --git a/apps/projects/views.py b/apps/projects/views.py index 960389d8..f8400eb9 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -20,25 +20,15 @@ from rest_framework.response import Response from simple_history.utils import update_change_reason -from apps.accounts.models import PeopleGroupLocation, ProjectUser +from apps.accounts.models import ProjectUser from apps.accounts.permissions import HasBasePermission -from apps.accounts.serializers import PeopleGroupLocationSuperLightSerializer from apps.analytics.models import Stat from apps.commons.cache import clear_cache_with_key, redis_cache_view from apps.commons.permissions import IsOwner, ReadOnly from apps.commons.utils import map_action_to_permission -from apps.commons.views import ( - MultipleIDViewsetMixin, - NestedOrganizationViewMixins, - NestedProjectViewMixins, -) +from apps.commons.views import MultipleIDViewsetMixin, NestedProjectViewMixins from apps.files.models import Image from apps.files.views import ImageStorageView -from apps.newsfeed.models import EventLocation, NewsLocation -from apps.newsfeed.serializers import ( - EventLocationSerializerLight, - NewsLocationSerializerLight, -) from apps.notifications.tasks import ( notify_group_as_member_added, notify_group_member_deleted, @@ -49,7 +39,6 @@ notify_new_private_message, notify_ready_for_review, ) -from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission from apps.organizations.utils import get_below_hierarchy_codes from apps.projects.exceptions import ( @@ -61,7 +50,6 @@ from .models import ( BlogEntry, LinkedProject, - Location, Project, ProjectMessage, ProjectTab, @@ -124,13 +112,6 @@ def get_queryset(self) -> QuerySet: "categories", "tags", "organizations", - "reviews", - "locations", - "announcements", - "links", - "files", - "images", - "blog_entries", ) ) @@ -308,7 +289,7 @@ def similar(self, request, *args, **kwargs): return self.get_paginated_response(serializer.data) -class ProjectHeaderView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectHeaderView(NestedProjectViewMixins, ImageStorageView): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -318,27 +299,21 @@ class ProjectHeaderView(MultipleIDViewsetMixin, ImageStorageView): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - if "project_id" in self.kwargs: - return Image.objects.filter(project_header__id=self.kwargs["project_id"]) - return Image.objects.none() + return Image.objects.filter(project_header=self.project) @staticmethod def upload_to(instance, filename) -> str: return f"project/header/{uuid.uuid4()}#{instance.name}" def add_image_to_model(self, image): - if "project_id" in self.kwargs: - project = Project.objects.get(id=self.kwargs["project_id"]) - project.header_image = image - project.save() - return f"/v1/project/{self.kwargs['project_id']}/header/{image.id}" - return None + self.project.header_image = image + self.project.save() + return f"/v1/project/{self.project}/header/{image.id}" -class ProjectImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectImagesView(NestedProjectViewMixins, ImageStorageView): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -348,19 +323,13 @@ class ProjectImagesView(MultipleIDViewsetMixin, ImageStorageView): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - 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(projects=self.kwargs["project_id"]), - project_related_name="projects", - ) - # Retrieve images before project is posted - if self.request.user.is_authenticated: - qs = qs | Image.objects.filter(owner=self.request.user) - return qs.distinct() - return Image.objects.none() + qs = self.project.images.all() + # Retrieve images before project 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: @@ -371,17 +340,13 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - project = Project.objects.get(id=self.kwargs["project_id"]) - project.images.add(image) - project.save() - return f"/v1/project/{self.kwargs['project_id']}/image/{image.id}" - return None + self.project.images.add(image) + self.project.save() + return f"/v1/project/{self.project.id}/image/{image.id}" class ProjectMemberViewSet( NestedProjectViewMixins, - MultipleIDViewsetMixin, viewsets.ModelViewSet, ): serializer_class = ProjectTeamMembersSerializer @@ -398,7 +363,6 @@ class ProjectMemberViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectUser]: return self.project.modules_by_user(self.request.user).members() @@ -499,7 +463,6 @@ def notify_remove_members(self, instances): class ProjectGroupViewSet( NestedProjectViewMixins, - MultipleIDViewsetMixin, viewsets.ModelViewSet, ): serializer_class = ProjectGroupSerializer @@ -516,7 +479,6 @@ class ProjectGroupViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: return ( @@ -526,9 +488,7 @@ def get_queryset(self) -> QuerySet: ) -class BlogEntryViewSet( - NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet -): +class BlogEntryViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = BlogEntrySerializer filter_backends = [DjangoFilterBackend, OrderingFilter] ordering_fields = ("created_at", "updated_at") @@ -542,7 +502,6 @@ class BlogEntryViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: return ( @@ -556,9 +515,7 @@ def perform_create(self, serializer): notify_new_blogentry.delay(instance.pk, self.request.user.pk) -class BlogEntryImagesView( - NestedProjectViewMixins, MultipleIDViewsetMixin, ImageStorageView -): +class BlogEntryImagesView(NestedProjectViewMixins, ImageStorageView): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -568,7 +525,6 @@ class BlogEntryImagesView( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): blogs_qs = self.project.modules_by_user(self.request.user).blogs() @@ -588,23 +544,17 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - if "blog_entry_id" in self.request.query_params: - blog_entry = BlogEntry.objects.get( - project_id=self.kwargs["project_id"], - id=self.request.query_params["blog_entry_id"], - ) - blog_entry.images.add(image) - blog_entry.save() - return ( - f"/v1/project/{self.kwargs['project_id']}/blog-entry-image/{image.id}" + if "blog_entry_id" in self.request.query_params: + blog_entry = BlogEntry.objects.get( + project=self.project, + id=self.request.query_params["blog_entry_id"], ) - return None + blog_entry.images.add(image) + blog_entry.save() + return f"/v1/project/{self.project.id}/blog-entry-image/{image.id}" -class GoalViewSet( - NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet -): +class GoalViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = GoalSerializer filter_backends = [DjangoFilterBackend] lookup_field = "id" @@ -617,15 +567,12 @@ class GoalViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet: return self.project.modules_by_user(self.request.user).goals() -class LocationViewSet( - NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet -): +class LocationViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = LocationSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -638,7 +585,6 @@ class LocationViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): return self.project.modules_by_user(self.request.user).locations() @@ -654,10 +600,9 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) -class HistoricalProjectViewSet(MultipleIDViewsetMixin, viewsets.ReadOnlyModelViewSet): +class HistoricalProjectViewSet(NestedProjectViewMixins, viewsets.ReadOnlyModelViewSet): lookup_field = "pk" permission_classes = [ReadOnly] - multiple_lookup_fields = [(Project, "project_id")] def get_serializer_class(self): if self.action == "list": @@ -665,20 +610,12 @@ def get_serializer_class(self): return ProjectVersionSerializer def get_queryset(self) -> QuerySet: - if "project_id" in self.kwargs: - project = get_object_or_404( - self.request.user.get_project_queryset(), - id=self.kwargs["project_id"], - ) - return apps.get_model("projects", "HistoricalProject").objects.filter( - history_relation=project, history_change_reason__isnull=False - ) - return apps.get_model("projects", "HistoricalProject").objects.none() + return apps.get_model("projects", "HistoricalProject").objects.filter( + history_relation=self.project, history_change_reason__isnull=False + ) -class LinkedProjectViewSet( - NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet -): +class LinkedProjectViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = LinkedProjectSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" @@ -690,7 +627,6 @@ class LinkedProjectViewSet( | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): return self.project.modules_by_user(self.request.user).linked_projects() @@ -781,13 +717,10 @@ def delete_many(self, request, *args, **kwargs): ) -class ProjectMessageViewSet( - NestedProjectViewMixins, MultipleIDViewsetMixin, viewsets.ModelViewSet -): +class ProjectMessageViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = ProjectMessageSerializer lookup_field = "id" lookup_value_regex = "[0-9]+" - multiple_lookup_fields = [(Project, "project_id")] def get_permissions(self): codename = map_action_to_permission(self.action, "projectmessage") @@ -812,19 +745,14 @@ def get_queryset(self): return queryset.select_related("author").prefetch_related("replies", "images") def perform_create(self, serializer): - message = serializer.save( - author=self.request.user, project_id=self.kwargs["project_id"] - ) + message = serializer.save(author=self.request.user, project_id=self.project.id) notify_new_private_message.delay(message.id) def perform_destroy(self, instance: ProjectMessage): instance.soft_delete() -class ProjectMessageImagesView( - NestedProjectViewMixins, MultipleIDViewsetMixin, ImageStorageView -): - multiple_lookup_fields = [(Project, "project_id")] +class ProjectMessageImagesView(NestedProjectViewMixins, ImageStorageView): def get_permissions(self): codename = map_action_to_permission(self.action, "projectmessage") @@ -841,13 +769,11 @@ def get_permissions(self): def get_queryset(self): messages_qs = self.project.modules_by_user(self.request.user).messages() - if "project_id" in self.kwargs: - 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) - return qs.distinct() - return Image.objects.none() + 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) + return qs.distinct() @staticmethod def upload_to(instance, filename) -> str: @@ -858,9 +784,7 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - return f"/v1/project/{self.kwargs['project_id']}/project-message-image/{image.id}" - return None + return f"/v1/project/{self.project.id}/project-message-image/{image.id}" class ProjectTabViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): @@ -1022,39 +946,3 @@ def add_image_to_model(self, image, *args, **kwargs): tab_item.save() return f"/v1/project/{self.kwargs['project_id']}/tab/{self.kwargs['tab_id']}/item-image/{image.id}" return None - - -class GeneralLocationView(NestedOrganizationViewMixins, viewsets.GenericViewSet): - http_method_names = ["get", "list"] - - def list(self, request, *args, **kwargs): - organizations_code = get_below_hierarchy_codes((self.organization.code,)) - organizations = Organization.objects.filter(code__in=organizations_code) - - qs_project = ( - request.user.get_project_related_queryset(Location.objects) - .select_related("project") - .filter(project__organizations__in=organizations) - ) - - qs_group = request.user.get_people_group_related_queryset( - PeopleGroupLocation.objects.filter( - people_group__organization__in=organizations - ) - ).select_related("people_group") - - qs_news = request.user.get_news_related_queryset( - NewsLocation.objects.filter(news__organization__in=organizations) - ).select_related("news") - - qs_event = request.user.get_event_related_queryset( - EventLocation.objects.filter(event__organization__in=organizations) - ).select_related("event") - - data = { - "groups": PeopleGroupLocationSuperLightSerializer(qs_group, many=True).data, - "projects": LocationSerializer(qs_project, many=True).data, - "news": NewsLocationSerializerLight(qs_news, many=True).data, - "event": EventLocationSerializerLight(qs_event, many=True).data, - } - return Response(data, status=status.HTTP_200_OK) From a6897f7d0deae16ab27d33c4fc6cea08366810e6 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 13:01:53 +0200 Subject: [PATCH 28/42] test: fix announcements --- .../tests/views/test_announcement.py | 67 +++++++++++++------ apps/announcements/urls.py | 2 +- apps/announcements/views.py | 2 +- apps/commons/tests/test_multiple_lookups.py | 10 ++- apps/commons/tests/test_process_text.py | 6 +- .../tasks/test_announcement_notifications.py | 4 +- .../tests/views/test_locked_project.py | 8 ++- .../tests/views/test_project_history.py | 6 +- apps/projects/urls.py | 2 +- .../test_announcement_translated_fields.py | 10 ++- 10 files changed, 78 insertions(+), 39 deletions(-) diff --git a/apps/announcements/tests/views/test_announcement.py b/apps/announcements/tests/views/test_announcement.py index b8728705..f76e5e09 100644 --- a/apps/announcements/tests/views/test_announcement.py +++ b/apps/announcements/tests/views/test_announcement.py @@ -49,7 +49,7 @@ def test_create_announcement(self, role, expected_status_code): "project_id": self.project.id, } response = self.client.post( - reverse("Announcement-list", args=(self.project.id,)), data=payload + reverse("Project-Announcement-list", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, expected_status_code) if expected_status_code == status.HTTP_201_CREATED: @@ -88,7 +88,7 @@ def test_update_announcement(self, role, expected_status_code): payload = {"description": faker.text()} response = self.client.patch( reverse( - "Announcement-detail", + "Project-Announcement-detail", args=(self.project.id, self.announcement.id), ), data=payload, @@ -124,7 +124,9 @@ def test_delete_announcement(self, role, expected_status_code): user = self.get_parameterized_test_user(role, instances=[self.project]) self.client.force_authenticate(user) response = self.client.delete( - reverse("Announcement-detail", args=(self.project.id, announcement.id)) + reverse( + "Project-Announcement-detail", args=(self.project.id, announcement.id) + ) ) self.assertEqual(response.status_code, expected_status_code) if expected_status_code == status.HTTP_204_NO_CONTENT: @@ -183,21 +185,27 @@ def test_list_announcements(self, role, retrieved_announcements): ) self.client.force_authenticate(user) response = self.client.get( - reverse("Announcement-list", args=(announcement.project.id,)) + reverse("Project-Announcement-list", args=(announcement.project.id,)) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if visibility in retrieved_announcements: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], announcement.id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) user = self.get_parameterized_test_user( role, instances=list(self.projects.values()) ) self.client.force_authenticate(user) - read_response = self.client.get(reverse("Read-announcement-list")) + read_response = self.client.get(reverse("Read-Announcement-list")) self.assertEqual(read_response.status_code, status.HTTP_200_OK) content = read_response.json()["results"] self.assertEqual(len(content), len(retrieved_announcements)) @@ -231,7 +239,7 @@ def test_retrieve_announcements(self, role, retrieved_announcements): self.client.force_authenticate(user) response = self.client.get( reverse( - "Announcement-detail", + "Project-Announcement-detail", args=(announcement.project.id, announcement.id), ) ) @@ -240,21 +248,33 @@ def test_retrieve_announcements(self, role, retrieved_announcements): content = response.json() self.assertEqual(content["id"], announcement.id) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) user = self.get_parameterized_test_user( role, instances=list(self.projects.values()) ) self.client.force_authenticate(user) read_response = self.client.get( - reverse("Read-announcement-detail", args=(announcement.id,)) + reverse("Read-Announcement-detail", args=(announcement.id,)) ) if visibility in retrieved_announcements: self.assertEqual(read_response.status_code, status.HTTP_200_OK) content = read_response.json() self.assertEqual(content["id"], announcement.id) else: - self.assertEqual(read_response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) @parameterized.expand( [ @@ -287,7 +307,7 @@ def test_apply_to_announcement(self, role, visible_announcements): } response = self.client.post( reverse( - "Announcement-apply", + "Project-Announcement-apply", args=(announcement.project.id, announcement.id), ), data=payload, @@ -295,7 +315,13 @@ def test_apply_to_announcement(self, role, visible_announcements): if visibility in visible_announcements: self.assertEqual(response.status_code, status.HTTP_200_OK) 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 FilterOrderAnnouncementTestCase(JwtAPITestCase): @@ -322,7 +348,7 @@ def setUpTestData(cls): def test_filter_from_date(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + reverse("Project-Announcement-list", args=(self.project.id,)) + f"?from_date={self.date_2.date()}" ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -335,7 +361,7 @@ def test_filter_from_date(self): def test_filter_to_date(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + reverse("Project-Announcement-list", args=(self.project.id,)) + f"?to_date={self.date_2.date()}" ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -348,7 +374,7 @@ def test_filter_to_date(self): def test_filter_from_date_with_null(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + reverse("Project-Announcement-list", args=(self.project.id,)) + f"?from_date_or_none={self.date_2.date()}" ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -365,7 +391,7 @@ def test_filter_from_date_with_null(self): def test_filter_to_date_with_null(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + reverse("Project-Announcement-list", args=(self.project.id,)) + f"?to_date_or_none={self.date_2.date()}" ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -382,7 +408,8 @@ def test_filter_to_date_with_null(self): def test_order_by_deadline(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + "?ordering=deadline" + reverse("Project-Announcement-list", args=(self.project.id,)) + + "?ordering=deadline" ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json()["results"] @@ -399,7 +426,7 @@ def test_order_by_deadline(self): def test_order_by_deadline_reverse(self): self.client.force_authenticate(self.user) response = self.client.get( - reverse("Announcement-list", args=(self.project.id,)) + reverse("Project-Announcement-list", args=(self.project.id,)) + "?ordering=-deadline" ) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/announcements/urls.py b/apps/announcements/urls.py index 4751caf0..87890475 100644 --- a/apps/announcements/urls.py +++ b/apps/announcements/urls.py @@ -4,5 +4,5 @@ router = DefaultRouter() router.register( - r"announcement", views.AnnouncementViewSet, basename="Read-announcement" + r"announcement", views.AnnouncementViewSet, basename="Read-Announcement" ) diff --git a/apps/announcements/views.py b/apps/announcements/views.py index e774013f..5345f011 100644 --- a/apps/announcements/views.py +++ b/apps/announcements/views.py @@ -41,7 +41,7 @@ def get_queryset(self): ).distinct() -class ProjectAnnouncementViewSet(NestedProjectViewMixins, viewsets.ModelViewSet): +class ProjectAnnouncementViewSet(NestedProjectViewMixins, AnnouncementViewSet): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, diff --git a/apps/commons/tests/test_multiple_lookups.py b/apps/commons/tests/test_multiple_lookups.py index 317e551a..69481b5e 100644 --- a/apps/commons/tests/test_multiple_lookups.py +++ b/apps/commons/tests/test_multiple_lookups.py @@ -348,20 +348,24 @@ def test_announcement_multiple_lookups(self): self.client.force_authenticate(self.superadmin) announcement = AnnouncementFactory(project=self.project) response = self.client.get( - reverse("Announcement-detail", args=(self.project.id, announcement.id)) + reverse( + "Project-Announcement-detail", args=(self.project.id, announcement.id) + ) ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json() self.assertEqual(content["id"], announcement.id) response = self.client.get( - reverse("Announcement-detail", args=(self.project.slug, announcement.id)) + reverse( + "Project-Announcement-detail", args=(self.project.slug, announcement.id) + ) ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json() self.assertEqual(content["id"], announcement.id) response = self.client.get( reverse( - "Announcement-detail", + "Project-Announcement-detail", args=(self.outdated_project_slug, announcement.id), ) ) diff --git a/apps/commons/tests/test_process_text.py b/apps/commons/tests/test_process_text.py index a3c3a951..0393dd05 100644 --- a/apps/commons/tests/test_process_text.py +++ b/apps/commons/tests/test_process_text.py @@ -142,7 +142,7 @@ def test_announcement(self): "description": text, } response = self.client.post( - reverse("Announcement-list", args=(self.project.id,)), data=payload + reverse("Project-Announcement-list", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) content = response.json() @@ -150,7 +150,9 @@ def test_announcement(self): payload = {"description": text} response = self.client.patch( - reverse("Announcement-detail", args=(self.project.id, content["id"])), + reverse( + "Project-Announcement-detail", args=(self.project.id, content["id"]) + ), data=payload, ) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/notifications/tests/tasks/test_announcement_notifications.py b/apps/notifications/tests/tasks/test_announcement_notifications.py index 5577449a..0d09efc1 100644 --- a/apps/notifications/tests/tasks/test_announcement_notifications.py +++ b/apps/notifications/tests/tasks/test_announcement_notifications.py @@ -58,7 +58,7 @@ def test_notification_task_called(self, notification_task): "project_id": project.id, } response = self.client.post( - reverse("Announcement-list", args=(project.id,)), data=payload + reverse("Project-Announcement-list", args=(project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -211,7 +211,7 @@ def test_notification_task_called(self, notification_task): **application, } self.client.post( - reverse("Announcement-apply", args=(project.id, announcement.id)), + reverse("Project-Announcement-apply", args=(project.id, announcement.id)), data=payload, ) notification_task.assert_called_once_with(announcement.pk, application) diff --git a/apps/projects/tests/views/test_locked_project.py b/apps/projects/tests/views/test_locked_project.py index 30035c9a..0378d607 100644 --- a/apps/projects/tests/views/test_locked_project.py +++ b/apps/projects/tests/views/test_locked_project.py @@ -222,7 +222,7 @@ def test_add_locked_project_related_objects(self, role, expected_code, mocked): "project_id": self.project.id, } response = self.client.post( - reverse("Announcement-list", args=(self.project.id,)), data=payload + reverse("Project-Announcement-list", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_403_FORBIDDEN: @@ -320,7 +320,7 @@ def test_update_locked_project_related_objects(self, role, expected_code): payload = {"title": faker.sentence()} response = self.client.patch( reverse( - "Announcement-detail", + "Project-Announcement-detail", args=(self.project.id, self.announcement.id), ), data=payload, @@ -428,7 +428,9 @@ def test_destroy_locked_project_related_objects(self, role, expected_code): # Destroy announcement announcement = AnnouncementFactory(project=self.project) response = self.client.delete( - reverse("Announcement-detail", args=(self.project.id, announcement.id)) + reverse( + "Project-Announcement-detail", args=(self.project.id, announcement.id) + ) ) self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_403_FORBIDDEN: diff --git a/apps/projects/tests/views/test_project_history.py b/apps/projects/tests/views/test_project_history.py index 93bddb1c..9eb5c2b7 100644 --- a/apps/projects/tests/views/test_project_history.py +++ b/apps/projects/tests/views/test_project_history.py @@ -1057,7 +1057,7 @@ def test_add_announcement(self): "project_id": project.id, } response = self.client.post( - reverse("Announcement-list", args=(project.id,)), data=payload + reverse("Project-Announcement-list", args=(project.id,)), data=payload ) history = HistoricalProject.objects.filter(history_relation__id=project.id) latest_version = history.order_by("-history_date").first() @@ -1088,7 +1088,7 @@ def test_update_announcement(self): ) payload = {"title": faker.sentence()} self.client.patch( - reverse("Announcement-detail", args=(project.id, announcement.id)), + reverse("Project-Announcement-detail", args=(project.id, announcement.id)), data=payload, ) history = HistoricalProject.objects.filter(history_relation__id=project.id) @@ -1119,7 +1119,7 @@ def test_remove_announcement(self): .count() ) self.client.delete( - reverse("Announcement-detail", args=(project.id, announcement.id)) + reverse("Project-Announcement-detail", args=(project.id, announcement.id)) ) 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 b6850f62..aae6e30d 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -59,7 +59,7 @@ project_router_register(router, r"follow", ProjectFollowViewSet, basename="Followed") project_router_register(router, r"review", ReviewViewSet, basename="Reviewed") project_router_register( - router, r"announcement", ProjectAnnouncementViewSet, basename="Announcement" + router, r"announcement", ProjectAnnouncementViewSet, basename="Project-Announcement" ) project_router_register(router, r"image", ProjectImagesView, basename="Project-images") project_router_register(router, r"header", ProjectHeaderView, basename="Project-header") diff --git a/services/translator/tests/views/test_announcement_translated_fields.py b/services/translator/tests/views/test_announcement_translated_fields.py index 036192aa..381d07a0 100644 --- a/services/translator/tests/views/test_announcement_translated_fields.py +++ b/services/translator/tests/views/test_announcement_translated_fields.py @@ -32,7 +32,7 @@ def test_create_announcement(self): "project_id": self.project.id, } response = self.client.post( - reverse("Announcement-list", args=(self.project.id,)), data=payload + reverse("Project-Announcement-list", args=(self.project.id,)), data=payload ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) content = response.json() @@ -62,7 +62,9 @@ def test_update_announcement(self): for translated_field in Announcement._auto_translated_fields } response = self.client.patch( - reverse("Announcement-detail", args=(self.project.id, announcement.pk)), + reverse( + "Project-Announcement-detail", args=(self.project.id, announcement.pk) + ), data=payload, ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -91,7 +93,9 @@ def test_delete_announcement(self): ).update(up_to_date=True) response = self.client.delete( - reverse("Announcement-detail", args=(self.project.id, announcement.pk)) + reverse( + "Project-Announcement-detail", args=(self.project.id, announcement.pk) + ) ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) auto_translated_fields = AutoTranslatedField.objects.filter( From a5c37b7589546e3bdc9b5b6a5cbbb94f64b50e0a Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 14:31:16 +0200 Subject: [PATCH 29/42] test: fix comments --- apps/feedbacks/tests/views/test_comment.py | 15 ++++++++++----- apps/feedbacks/views.py | 7 ++++--- services/google/tests/test_tasks.py | 4 ++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/feedbacks/tests/views/test_comment.py b/apps/feedbacks/tests/views/test_comment.py index 63d34d97..35fae2ab 100644 --- a/apps/feedbacks/tests/views/test_comment.py +++ b/apps/feedbacks/tests/views/test_comment.py @@ -59,7 +59,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")), @@ -78,9 +77,9 @@ def test_list_comments(self, role, retrieved_comments): ) self.client.force_authenticate(user) response = self.client.get(reverse("Comment-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if project_status in retrieved_comments: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], self.comments[project_status].id) self.assertEqual( @@ -88,7 +87,10 @@ def test_list_comments(self, role, retrieved_comments): self.replies[project_status].id, ) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class CreateCommentTestCase(JwtAPITestCase): @@ -142,7 +144,10 @@ def test_create_comment(self, role, created_comments): self.assertEqual(response.json()["content"], payload["content"]) self.assertEqual(response.json()["author"]["id"], user.id) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) def test_create_comment_anonymous(self): for project in self.projects.values(): diff --git a/apps/feedbacks/views.py b/apps/feedbacks/views.py index 7c1de37c..36c9379d 100644 --- a/apps/feedbacks/views.py +++ b/apps/feedbacks/views.py @@ -180,7 +180,7 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self) -> QuerySet[Comment]: - qs = self.project.modules_by_user(self.request.user).comment() + qs = self.project.modules_by_user(self.request.user).comments() if self.action in ["retrieve", "list"]: qs = qs.exclude( @@ -220,8 +220,9 @@ def get_permissions(self): ] return super().get_permissions() - def get_queryset(self): - qs = self.project.modules_by_user(self.request.user).comment() + def get_queryset(self) -> QuerySet[Image]: + comments_qs = self.project.modules_by_user(self.request.user).comments() + qs = Image.objects.filter(comments__in=comments_qs) if self.request.user.is_authenticated: qs = qs | Image.objects.filter(owner=self.request.user) return qs.distinct() diff --git a/services/google/tests/test_tasks.py b/services/google/tests/test_tasks.py index 7c8bddec..da6e26d7 100644 --- a/services/google/tests/test_tasks.py +++ b/services/google/tests/test_tasks.py @@ -290,7 +290,7 @@ def test_create_google_group_with_email(self, mocked, mocked_delay): ), data=payload_team, ) - self.assertEqual(response_team.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) @@ -431,7 +431,7 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): ), data=payload_team, ) - self.assertEqual(response_team.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) From fcfa63fcfa4b97f8034dde57bee7f8a3e135a56d Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 2 Jun 2026 16:19:44 +0200 Subject: [PATCH 30/42] test: fix attachments --- .../feedbacks/tests/views/test_comment_images.py | 16 ++++++++++++++-- apps/files/tests/views/test_attachment_file.py | 12 +++++++++--- apps/files/tests/views/test_attachment_link.py | 12 +++++++++--- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/feedbacks/tests/views/test_comment_images.py b/apps/feedbacks/tests/views/test_comment_images.py index c6b4dea1..e908b8e5 100644 --- a/apps/feedbacks/tests/views/test_comment_images.py +++ b/apps/feedbacks/tests/views/test_comment_images.py @@ -71,7 +71,13 @@ def test_retrieve_comment_image(self, role, retrieved_comments): if publication_status in retrieved_comments: 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 CreateCommentImageTestCase(JwtAPITestCase): @@ -146,7 +152,13 @@ def test_create_comment_image(self, role, created_images): self.assertEqual(content["top"], payload["top"]) self.assertEqual(content["natural_ratio"], payload["natural_ratio"]) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) def test_create_comment_image_anonymous(self): for project in self.projects.values(): diff --git a/apps/files/tests/views/test_attachment_file.py b/apps/files/tests/views/test_attachment_file.py index b0b0b96a..69edeca9 100644 --- a/apps/files/tests/views/test_attachment_file.py +++ b/apps/files/tests/views/test_attachment_file.py @@ -182,13 +182,19 @@ def test_list_attachment_files(self, role, retrieved_files): response = self.client.get( reverse("AttachmentFile-list", args=(project.id,)) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_files: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], self.files[publication_status].id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) class ValidateAttachmentFileTestCase(JwtAPITestCase): diff --git a/apps/files/tests/views/test_attachment_link.py b/apps/files/tests/views/test_attachment_link.py index 43ebb41e..7b342385 100644 --- a/apps/files/tests/views/test_attachment_link.py +++ b/apps/files/tests/views/test_attachment_link.py @@ -178,13 +178,19 @@ def test_list_attachment_links(self, role, retrieved_links): response = self.client.get( reverse("AttachmentLink-list", args=(project.id,)) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_links: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], self.links[publication_status].id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ), + ) class ValidateAttachmentLinkTestCase(JwtAPITestCase): From 8551ae5cf1ce061e29590230c29d3740cc0cb1f5 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 3 Jun 2026 12:33:49 +0200 Subject: [PATCH 31/42] feat: optimize search tags wikimedia --- apps/skills/utils.py | 34 +++++++++++++++++++++++++--------- apps/skills/views.py | 10 ++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/skills/utils.py b/apps/skills/utils.py index b7fd0176..f194ed39 100644 --- a/apps/skills/utils.py +++ b/apps/skills/utils.py @@ -86,20 +86,36 @@ def set_default_language_title_and_description( def update_or_create_wikipedia_tags(wikipedia_qids: list[str]) -> list[Tag]: - data = WikipediaService.get_by_ids(wikipedia_qids) - data = [set_default_language_title_and_description(tag) for tag in data] - tags = [] - for tag in data: + tags = WikipediaService.get_by_ids(wikipedia_qids) + tags = [set_default_language_title_and_description(tag) for tag in tags] + + tags_to_create = [] + all_ids = [] + all_fields = set() + for tag in tags: external_id = tag.pop("external_id") - tag, _ = Tag.objects.update_or_create( - external_id=external_id, type=Tag.TagType.WIKIPEDIA, defaults=tag + all_ids.append(external_id) + all_fields |= set(tag.keys()) + tags_to_create.append( + Tag(external_id=external_id, type=Tag.TagType.WIKIPEDIA, **tag) ) - tags.append(tag) + all_ids = [tag.external_id for tag in tags_to_create] + + # remove keys + all_fields.discard("external_id") + + all_tags = Tag.objects.bulk_create( + tags_to_create, + update_conflicts=True, + unique_fields=("external_id", "type"), + update_fields=list(all_fields), + ) + classification = TagClassification.get_or_create_default_classification( classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) - classification.tags.add(*tags) - return tags + classification.tags.add(*all_tags) + return all_tags def update_wikipedia_data(chunk_size: int = 50): diff --git a/apps/skills/views.py b/apps/skills/views.py index 12e05347..07e70d7e 100644 --- a/apps/skills/views.py +++ b/apps/skills/views.py @@ -258,14 +258,12 @@ def get_queryset(self): tag_classification_id = self.kwargs.get("tag_classification_id") if organization_code and not tag_classification_id: return Tag.objects.filter(organization__code=organization_code) + if organization_code and tag_classification_id: if tag_classification_id in TagClassification.reserved_slugs: - tag_classification_ids = [ - c.id - for c in self.get_enabled_classifications( - organization_code, tag_classification_id - ) - ] + tag_classification_ids = self.get_enabled_classifications( + organization_code, tag_classification_id + ).values_list("id", flat=True) else: tag_classification_ids = [tag_classification_id] wikipedia_classification = TagClassification.get_or_create_default_classification( From 49526cc67a8502e581887382bfb700bdbaf1a9d1 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 3 Jun 2026 12:51:17 +0200 Subject: [PATCH 32/42] fix: tags search --- apps/skills/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/skills/utils.py b/apps/skills/utils.py index f194ed39..6bf38faf 100644 --- a/apps/skills/utils.py +++ b/apps/skills/utils.py @@ -107,7 +107,7 @@ def update_or_create_wikipedia_tags(wikipedia_qids: list[str]) -> list[Tag]: all_tags = Tag.objects.bulk_create( tags_to_create, update_conflicts=True, - unique_fields=("external_id", "type"), + unique_fields=("external_id",), update_fields=list(all_fields), ) From bd54be40b4d3a23d40e5ab56147e3d6ce111256c Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 3 Jun 2026 14:24:03 +0200 Subject: [PATCH 33/42] test: fix create googlegroup --- services/google/tests/test_tasks.py | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/services/google/tests/test_tasks.py b/services/google/tests/test_tasks.py index da6e26d7..bbb389d8 100644 --- a/services/google/tests/test_tasks.py +++ b/services/google/tests/test_tasks.py @@ -283,14 +283,14 @@ def test_create_google_group_with_email(self, mocked, mocked_delay): content = response.json() people_group = PeopleGroup.objects.get(id=content["id"]) - response_team = self.client.post( - reverse( - "PeopleGroup-add-member", - args=(self.organization.code, people_group.id), - ), - data=payload_team, - ) - self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) + response_team = self.client.post( + reverse( + "PeopleGroup-add-member", + args=(self.organization.code, people_group.id), + ), + data=payload_team, + ) + self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) @@ -424,14 +424,14 @@ def test_create_google_group_without_email(self, mocked, mocked_delay): content = response.json() people_group = PeopleGroup.objects.get(id=content["id"]) - response_team = self.client.post( - reverse( - "PeopleGroup-add-member", - args=(self.organization.code, people_group.id), - ), - data=payload_team, - ) - self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) + response_team = self.client.post( + reverse( + "PeopleGroup-add-member", + args=(self.organization.code, people_group.id), + ), + data=payload_team, + ) + self.assertEqual(response_team.status_code, status.HTTP_204_NO_CONTENT) self.assertIsNotNone(people_group.google_group) mocked_create_group.assert_called_once_with(people_group) From a302930e658e34352ffaf4f2193c2ff7cf986b5b Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 9 Jun 2026 12:26:46 +0200 Subject: [PATCH 34/42] fix: tags search --- apps/search/filters.py | 219 +++++++++++++++++++++-------------------- apps/skills/utils.py | 9 +- apps/skills/views.py | 4 +- 3 files changed, 121 insertions(+), 111 deletions(-) diff --git a/apps/search/filters.py b/apps/search/filters.py index 67cd8429..a99d15dd 100644 --- a/apps/search/filters.py +++ b/apps/search/filters.py @@ -1,5 +1,18 @@ -from django.db.models import BigIntegerField, Case, F, JSONField, Q, Value, When +from functools import partial, wraps + +from django.db.models import ( + BigIntegerField, + Case, + F, + JSONField, + Q, + QuerySet, + Value, + When, +) from django_filters import rest_framework as filters +from numpy import number +from opensearchpy.helpers.response import Response from rest_framework.filters import SearchFilter from rest_framework.settings import api_settings @@ -13,114 +26,108 @@ from .models import SearchObject -def MultiMatchSearchFieldsFilter( # noqa: N802 - index: str, - fields: list[str] | None, - highlight: list[str] | None = None, - highlight_size: int = 150, -): - class _MultiMatchSearchFieldsFilter(SearchFilter): - def filter_queryset(self, request, queryset, view): - query = self.get_search_terms(request) - if isinstance(query, list): - query = " ".join(query) - if query: - limit = request.query_params.get("limit", api_settings.PAGE_SIZE) - offset = request.query_params.get("offset", 0) - response = OpenSearchService.multi_match_search( - indices=index, - fields=fields, - query=query, - highlight=highlight, - highlight_size=highlight_size, - limit=limit, - offset=offset, - id=list(queryset.values_list("id", flat=True)), - ) - ids = [hit.id for hit in response.hits] - if not ids: - return queryset.none() - queryset = queryset.filter(id__in=ids).annotate( - ordering=ArrayPosition(ids, F("id"), base_field=BigIntegerField()) - ) - if highlight: - queryset = queryset.annotate( - highlight=Case( - *[ - When( - id=hit.id, - then=Value( - ( - hit.meta.highlight.to_dict() - if hasattr(hit.meta, "highlight") - else {} - ), - output_field=JSONField(), - ), - ) - for hit in response.hits - ] - ) - ) - return queryset.order_by("ordering") +class AbstractOpensearch(SearchFilter): + def __init__( + self, + index: str, + fields: list[str] | None = None, + highlight: list[str] | None = None, + highlight_size: int = 150, + ): + + self.index = index + self.fields = fields + self.highlight = highlight + self.highlight_size = highlight_size + + @classmethod + def prepare(cls, **kw): + return wraps(cls)(partial(cls, **kw)) + + def search(self, request) -> str: + query = self.get_search_terms(request) + if isinstance(query, list): + query = " ".join(query) + + return query + + def opensearch( + self, queryset: QuerySet, query: str, limit: number, offset: number + ) -> Response: + """method to return result from opensearch""" + raise NotImplementedError + + def filter_queryset(self, request, queryset, view): + search = self.search(request) + if not search: return queryset - return _MultiMatchSearchFieldsFilter - - -def MultiMatchPrefixSearchFieldsFilter( # noqa: N802 - index: str, - fields: list[str] | None, - highlight: list[str] | None = None, - highlight_size: int = 150, -): - class _MultiMatchPrefixSearchFieldsFilter(SearchFilter): - def filter_queryset(self, request, queryset, view): - query = self.get_search_terms(request) - if isinstance(query, list): - query = " ".join(query) - if query: - limit = request.query_params.get("limit", api_settings.PAGE_SIZE) - offset = request.query_params.get("offset", 0) - response = OpenSearchService.multi_match_prefix_search( - indices=index, - fields=fields, - query=query, - highlight=highlight, - highlight_size=highlight_size, - limit=limit, - offset=offset, - id=list(queryset.values_list("id", flat=True)), - ) - ids = [hit.id for hit in response.hits] - if not ids: - return queryset.none() - queryset = queryset.filter(id__in=ids).annotate( - ordering=ArrayPosition(ids, F("id"), base_field=BigIntegerField()) - ) - if highlight: - queryset = queryset.annotate( - highlight=Case( - *[ - When( - id=hit.id, - then=Value( - ( - hit.meta.highlight.to_dict() - if hasattr(hit.meta, "highlight") - else {} - ), - output_field=JSONField(), - ), - ) - for hit in response.hits - ] + limit = request.query_params.get("limit", api_settings.PAGE_SIZE) + offset = request.query_params.get("offset", 0) + + response = self.opensearch(queryset, search, limit, offset) + + ids = [hit.id for hit in response.hits] + + if not ids: + return queryset.none() + + queryset = queryset.filter(id__in=ids).annotate( + ordering=ArrayPosition(ids, F("id"), base_field=BigIntegerField()) + ) + if self.highlight: + queryset = queryset.annotate( + highlight=Case( + *[ + When( + id=hit.id, + then=Value( + ( + hit.meta.highlight.to_dict() + if hasattr(hit.meta, "highlight") + else {} + ), + output_field=JSONField(), + ), ) - ) - return queryset.order_by("ordering") - return queryset - - return _MultiMatchPrefixSearchFieldsFilter + for hit in response.hits + ] + ) + ) + return queryset.order_by("ordering") + + +class MultiMatchSearchFieldsFilter(AbstractOpensearch): + + def opensearch( + self, queryset: QuerySet, query: str, limit: int, offset: int + ) -> Response: + return OpenSearchService.multi_match_search( + indices=self.index, + fields=self.fields, + query=query, + highlight=self.highlight, + highlight_size=self.highlight_size, + limit=limit, + offset=offset, + id=list(queryset.values_list("id", flat=True)), + ) + + +class MultiMatchPrefixSearchFieldsFilter(AbstractOpensearch): + def opensearch( + self, queryset: QuerySet, query: str, limit: int, offset: int + ) -> Response: + return OpenSearchService.multi_match_prefix_search( + indices=self.index, + fields=self.fields, + query=query, + highlight=self.highlight, + highlight_size=self.highlight_size, + limit=limit, + offset=offset, + id=list(queryset.values_list("id", flat=True)), + ) class SearchObjectFilter(filters.FilterSet): diff --git a/apps/skills/utils.py b/apps/skills/utils.py index 6bf38faf..a6e97ad7 100644 --- a/apps/skills/utils.py +++ b/apps/skills/utils.py @@ -3,6 +3,7 @@ from django.conf import settings +from apps.search.documents import TagDocument from services.esco.interface import EscoService from services.wikipedia.interface import WikipediaService @@ -90,11 +91,9 @@ def update_or_create_wikipedia_tags(wikipedia_qids: list[str]) -> list[Tag]: tags = [set_default_language_title_and_description(tag) for tag in tags] tags_to_create = [] - all_ids = [] all_fields = set() for tag in tags: external_id = tag.pop("external_id") - all_ids.append(external_id) all_fields |= set(tag.keys()) tags_to_create.append( Tag(external_id=external_id, type=Tag.TagType.WIKIPEDIA, **tag) @@ -114,7 +113,11 @@ def update_or_create_wikipedia_tags(wikipedia_qids: list[str]) -> list[Tag]: classification = TagClassification.get_or_create_default_classification( classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) - classification.tags.add(*all_tags) + to_adds = Tag.objects.filter(external_id__in=all_ids) + classification.tags.add(*to_adds) + + # regenerate index + TagDocument().update(list(to_adds), action="index") return all_tags diff --git a/apps/skills/views.py b/apps/skills/views.py index 07e70d7e..6bac58c6 100644 --- a/apps/skills/views.py +++ b/apps/skills/views.py @@ -186,8 +186,8 @@ def remove_tags(self, request, *args, **kwargs): class TagViewSet(MultipleIDViewsetMixin, viewsets.ModelViewSet): serializer_class = TagSerializer filter_backends = ( - MultiMatchPrefixSearchFieldsFilter( - f"{settings.OPENSEARCH_INDEX_PREFIX}-tag", + MultiMatchPrefixSearchFieldsFilter.prepare( + index=f"{settings.OPENSEARCH_INDEX_PREFIX}-tag", fields=["title^5", "alternative_titles^3", "content^1"], highlight=["title", "content"], ), From 5f9ac47e2e377296e807a09b8b2a31e8f5968982 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 10 Jun 2026 09:50:29 +0200 Subject: [PATCH 35/42] test: fix tests --- apps/skills/tests/tasks/test_wikipedia_tasks.py | 16 ++++++++++++---- apps/skills/utils.py | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/skills/tests/tasks/test_wikipedia_tasks.py b/apps/skills/tests/tasks/test_wikipedia_tasks.py index 93f16bb3..092e3cdb 100644 --- a/apps/skills/tests/tasks/test_wikipedia_tasks.py +++ b/apps/skills/tests/tasks/test_wikipedia_tasks.py @@ -10,8 +10,9 @@ class WikipediaServiceTestCase(WikipediaTestCase): + @patch("apps.search.documents.TagDocument.update") @patch("services.wikipedia.interface.WikipediaService.wbgetentities") - def test_update_or_create_tag(self, mocked): + def test_update_or_create_tag(self, mocked, tag_doc_updated): wikipedia_qids = [self.get_random_wikipedia_qid() for _ in range(3)] mocked.return_value = self.get_wikipedia_tags_mocked_return(wikipedia_qids) update_or_create_wikipedia_tags(wikipedia_qids) @@ -19,6 +20,7 @@ def test_update_or_create_tag(self, mocked): classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) classification_tags = classification.tags.all() + tag_doc_updated.assert_called_once() for wikipedia_qid in wikipedia_qids: tag = Tag.objects.get(external_id=wikipedia_qid) self.assertEqual(tag.title_fr, f"title_fr_{wikipedia_qid}") @@ -29,8 +31,9 @@ def test_update_or_create_tag(self, mocked): self.assertEqual(tag.description, f"description_en_{wikipedia_qid}") self.assertIn(tag, classification_tags) + @patch("apps.search.documents.TagDocument.update") @patch("services.wikipedia.interface.WikipediaService.wbgetentities") - def test_update_or_create_tag_no_english(self, mocked): + def test_update_or_create_tag_no_english(self, mocked, tag_doc_updated): wikipedia_qids = [self.get_random_wikipedia_qid() for _ in range(3)] mocked.return_value = self.get_wikipedia_tags_mocked_return( wikipedia_qids, en=False @@ -40,6 +43,7 @@ def test_update_or_create_tag_no_english(self, mocked): classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) classification_tags = classification.tags.all() + tag_doc_updated.assert_called_once() for wikipedia_qid in wikipedia_qids: tag = Tag.objects.get(external_id=wikipedia_qid) self.assertEqual(tag.title_fr, f"title_fr_{wikipedia_qid}") @@ -50,8 +54,9 @@ def test_update_or_create_tag_no_english(self, mocked): self.assertEqual(tag.description, f"description_fr_{wikipedia_qid}") self.assertIn(tag, classification_tags) + @patch("apps.search.documents.TagDocument.update") @patch("services.wikipedia.interface.WikipediaService.wbgetentities") - def test_update_or_create_tag_no_french(self, mocked): + def test_update_or_create_tag_no_french(self, mocked, tag_doc_updated): wikipedia_qids = [self.get_random_wikipedia_qid() for _ in range(3)] mocked.return_value = self.get_wikipedia_tags_mocked_return( wikipedia_qids, fr=False @@ -61,6 +66,7 @@ def test_update_or_create_tag_no_french(self, mocked): classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) classification_tags = classification.tags.all() + tag_doc_updated.assert_called_once() for wikipedia_qid in wikipedia_qids: tag = Tag.objects.get(external_id=wikipedia_qid) self.assertEqual(tag.title_fr, None) @@ -71,8 +77,9 @@ def test_update_or_create_tag_no_french(self, mocked): self.assertEqual(tag.description, f"description_en_{wikipedia_qid}") self.assertIn(tag, classification_tags) + @patch("apps.search.documents.TagDocument.update") @patch("services.wikipedia.interface.WikipediaService.wbgetentities") - def test_update_or_create_tag_no_english_no_french(self, mocked): + def test_update_or_create_tag_no_english_no_french(self, mocked, tag_doc_updated): wikipedia_qids = [self.get_random_wikipedia_qid() for _ in range(3)] mocked.return_value = self.get_wikipedia_tags_mocked_return( wikipedia_qids, en=False, fr=False @@ -82,6 +89,7 @@ def test_update_or_create_tag_no_english_no_french(self, mocked): classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) classification_tags = classification.tags.all() + tag_doc_updated.assert_called_once() for wikipedia_qid in wikipedia_qids: tag = Tag.objects.get(external_id=wikipedia_qid) self.assertEqual(tag.title_fr, None) diff --git a/apps/skills/utils.py b/apps/skills/utils.py index a6e97ad7..083e8dd3 100644 --- a/apps/skills/utils.py +++ b/apps/skills/utils.py @@ -113,11 +113,11 @@ def update_or_create_wikipedia_tags(wikipedia_qids: list[str]) -> list[Tag]: classification = TagClassification.get_or_create_default_classification( classification_type=TagClassification.TagClassificationType.WIKIPEDIA ) - to_adds = Tag.objects.filter(external_id__in=all_ids) + to_adds = list(Tag.objects.filter(external_id__in=all_ids)) classification.tags.add(*to_adds) # regenerate index - TagDocument().update(list(to_adds), action="index") + TagDocument().update(to_adds, action="index") return all_tags From 116531e3d0d081209187567a3b9cd2e66c406c8c Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 10 Jun 2026 19:19:44 +0200 Subject: [PATCH 36/42] pre-models --- apps/commons/views.py | 15 +++++++- apps/modules/project.py | 4 ++ apps/projects/views.py | 83 ++++++++++++++++------------------------- 3 files changed, 50 insertions(+), 52 deletions(-) diff --git a/apps/commons/views.py b/apps/commons/views.py index d1ff0ca8..8e176d6d 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -9,7 +9,7 @@ from apps.accounts.permissions import ProjectNestedPermission from apps.organizations.models import Organization from apps.organizations.utils import get_below_hierarchy_codes -from apps.projects.models import Project +from apps.projects.models import Project, ProjectTab from .mixins import HasMultipleIDs @@ -184,6 +184,19 @@ def get_permissions(self): return [ProjectNestedPermission(), *super().get_permissions()] +class NestedProjectTabViewMixins: + tab: ProjectTab + project: Project + + def initial(self, request, *args, **kwargs): + + self.tab = get_object_or_404( + self.project.modules_by_user(request.user).tabs(), pk=kwargs["tab_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/modules/project.py b/apps/modules/project.py index 9c7e8aa5..9192495f 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -12,6 +12,7 @@ Location, Project, ProjectMessage, + ProjectTab, ) @@ -114,3 +115,6 @@ def reviews(self) -> QuerySet[Review]: def messages(self) -> QuerySet[ProjectMessage]: return self.instance.messages.all() + + def tabs(self) -> QuerySet[ProjectTab]: + return self.instance.additional_tabs.all() diff --git a/apps/projects/views.py b/apps/projects/views.py index f8400eb9..d26c34bb 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -26,7 +26,11 @@ from apps.commons.cache import clear_cache_with_key, redis_cache_view from apps.commons.permissions import IsOwner, ReadOnly from apps.commons.utils import map_action_to_permission -from apps.commons.views import MultipleIDViewsetMixin, NestedProjectViewMixins +from apps.commons.views import ( + MultipleIDViewsetMixin, + NestedProjectTabViewMixins, + NestedProjectViewMixins, +) from apps.files.models import Image from apps.files.views import ImageStorageView from apps.notifications.tasks import ( @@ -787,7 +791,7 @@ def add_image_to_model(self, image, *args, **kwargs): return f"/v1/project/{self.project.id}/project-message-image/{image.id}" -class ProjectTabViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectTabViewset(NestedProjectViewMixins, viewsets.ModelViewSet): """Project tabs.""" serializer_class = ProjectTabSerializer @@ -802,23 +806,15 @@ class ProjectTabViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectTab]: - if "project_id" in self.kwargs: - return self.request.user.get_project_related_queryset( - ProjectTab.objects.filter(project=self.kwargs["project_id"]) - ) - return ProjectTab.objects.none() + return self.project.modules_by_user(self.request.user).tabs() def perform_create(self, serializer: ProjectTabSerializer): - project_id = self.kwargs.get("project_id") - if project_id: - project = get_object_or_404(Project, id=project_id) - serializer.save(project=project) + serializer.save(project=self.project) -class ProjectTabImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectTabImagesView(NestedProjectViewMixins, ImageStorageView): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -828,19 +824,15 @@ class ProjectTabImagesView(MultipleIDViewsetMixin, ImageStorageView): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - 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(project_tabs__project=self.kwargs["project_id"]), - project_related_name="project_tabs__project", - ) - # Retrieve images before tab is posted - if self.request.user.is_authenticated: - qs = qs | Image.objects.filter(owner=self.request.user) - return qs.distinct() - return Image.objects.none() + tabs_qs = self.project.modules_by_user(self.request.user).tabs() + + qs = Image.objects.filter(project_tabs__in=tabs_qs) + # Retrieve images before tab 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: @@ -851,19 +843,19 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - if "tab_id" in self.request.query_params: - project_tab = ProjectTab.objects.get( - project_id=self.kwargs["project_id"], - id=self.request.query_params["tab_id"], - ) - project_tab.images.add(image) - project_tab.save() - return f"/v1/project/{self.kwargs['project_id']}/tab-image/{image.id}" - return None + if "tab_id" in self.request.query_params: + project_tab = ProjectTab.objects.get( + project=self.project, + id=self.request.query_params["tab_id"], + ) + project_tab.images.add(image) + project_tab.save() + return f"/v1/project/{self.project.id}/tab-image/{image.id}" -class ProjectTabItemViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectTabItemViewset( + NestedProjectViewMixins, NestedProjectTabViewMixins, viewsets.ModelViewSet +): """Project tabs.""" serializer_class = ProjectTabItemSerializer @@ -878,28 +870,17 @@ class ProjectTabItemViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectTabItem]: - if "project_id" in self.kwargs and "tab_id" in self.kwargs: - return self.request.user.get_project_related_queryset( - ProjectTabItem.objects.filter( - tab__project=self.kwargs["project_id"], - tab=self.kwargs["tab_id"], - ), - project_related_name="tab__project", - ) - return ProjectTabItem.objects.none() + return self.tab.items.all() def perform_create(self, serializer: ProjectTabItemSerializer): - project_id = self.kwargs.get("project_id") - tab_id = self.kwargs.get("tab_id") - if project_id and tab_id: - tab = get_object_or_404(ProjectTab, id=tab_id, project_id=project_id) - serializer.save(tab=tab) + serializer.save(tab=self.tab) -class ProjectTabItemImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectTabItemImagesView( + NestedProjectViewMixins, NestedProjectTabViewMixins, ImageStorageView +): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, From af398d5b4a246db99e1717045c6d68d2ef485ed6 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 11 Jun 2026 12:27:10 +0200 Subject: [PATCH 37/42] add modules tabs --- apps/modules/__init__.py | 3 ++- apps/modules/tab.py | 12 ++++++++++++ apps/projects/models.py | 4 +++- apps/projects/serializers.py | 3 ++- apps/projects/views.py | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 apps/modules/tab.py diff --git a/apps/modules/__init__.py b/apps/modules/__init__.py index 38b9d7e4..a8a2f3f7 100644 --- a/apps/modules/__init__.py +++ b/apps/modules/__init__.py @@ -1,4 +1,5 @@ from .group import PeopleGroupModules from .project import ProjectModules +from .tab import TabModules -__all__ = ["PeopleGroupModules", "ProjectModules"] +__all__ = ["PeopleGroupModules", "ProjectModules", "TabModules"] diff --git a/apps/modules/tab.py b/apps/modules/tab.py new file mode 100644 index 00000000..e9ca289f --- /dev/null +++ b/apps/modules/tab.py @@ -0,0 +1,12 @@ +from django.db.models import QuerySet + +from apps.modules.base import AbstractModules, register_module +from apps.projects.models import ProjectTab, ProjectTabItem + + +@register_module(ProjectTab) +class TabModules(AbstractModules): + instance: ProjectTab + + def items(self) -> QuerySet[ProjectTabItem]: + return self.instance.items.all() diff --git a/apps/projects/models.py b/apps/projects/models.py index e0ab3d80..f602f6bb 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1000,7 +1000,9 @@ def is_owned_by(self, user: "ProjectUser") -> bool: return self.author == user -class ProjectTab(HasAutoTranslatedFields, ProjectRelated, models.Model): +class ProjectTab( + HasRelatedModules, HasAutoTranslatedFields, ProjectRelated, models.Model +): """A tab in the project page. Attributes diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 223c50de..ee612752 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -897,6 +897,7 @@ def get_string_images_kwargs( @auto_translated class ProjectTabSerializer( + ModulesSerializers, StringsImagesSerializer, serializers.ModelSerializer, ): @@ -911,7 +912,7 @@ class ProjectTabSerializer( class Meta: model = ProjectTab - read_only_fields = ["id"] + read_only_fields = ["id", "modules"] fields = read_only_fields + [ "type", "title", diff --git a/apps/projects/views.py b/apps/projects/views.py index d26c34bb..60c1a91f 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -6,7 +6,7 @@ from django.core.cache import cache from django.db import transaction from django.db.models import QuerySet -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema From d159d839f41859e1e6f1bb3dba069fdd935137ba Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 11 Jun 2026 16:21:50 +0200 Subject: [PATCH 38/42] clenaup models --- apps/projects/exceptions.py | 6 --- apps/projects/factories.py | 1 - ...e_alter_projecttab_description_and_more.py | 32 +++++++++++++++ apps/projects/models.py | 15 ++----- apps/projects/serializers.py | 7 ---- apps/projects/tests/views/test_project_tab.py | 40 ------------------- ...test_project_tab_item_translated_fields.py | 4 +- .../test_project_tab_translated_fields.py | 1 - 8 files changed, 37 insertions(+), 69 deletions(-) create mode 100644 apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py diff --git a/apps/projects/exceptions.py b/apps/projects/exceptions.py index c17bbb0b..df055953 100644 --- a/apps/projects/exceptions.py +++ b/apps/projects/exceptions.py @@ -127,9 +127,3 @@ class ProjectMessageReplyToSelfError(ValidationError): status_code = status.HTTP_400_BAD_REQUEST default_detail = _("A message cannot be a reply to itself") default_code = "project_message_reply_to_self_error" - - -class ProjectTabChangeTypeError(ValidationError): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = _("You cannot change the type of a project's tab") - default_code = "project_tab_change_type_error" diff --git a/apps/projects/factories.py b/apps/projects/factories.py index e5b0ad88..09468817 100644 --- a/apps/projects/factories.py +++ b/apps/projects/factories.py @@ -168,7 +168,6 @@ class ProjectTabFactory(factory.django.DjangoModelFactory): icon = factory.Faker("word") title = factory.Faker("sentence") description = factory.Faker("text") - type = FuzzyChoice(ProjectTab.TabType.choices, getter=lambda c: c[0]) class Meta: model = ProjectTab diff --git a/apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py b/apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py new file mode 100644 index 00000000..0f17e204 --- /dev/null +++ b/apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.4 on 2026-06-11 14:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0003_alter_location_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="projecttab", + name="type", + ), + migrations.AlterField( + model_name="projecttab", + name="description", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="projecttab", + name="icon", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="projecttabitem", + name="content", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index f602f6bb..20269509 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1019,23 +1019,14 @@ class ProjectTab( auto_translated_fields: list[str] = ["title", "html:description"] - class TabType(models.TextChoices): - """Type of a tab.""" - - TEXT = "text" - BLOG = "blog" - project = models.ForeignKey( "projects.Project", on_delete=models.CASCADE, related_name="additional_tabs", ) - type = models.CharField( - max_length=32, choices=TabType.choices, default=TabType.TEXT - ) title = models.CharField(max_length=255) - description = models.TextField(blank=True) - icon = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True, null=True) + icon = models.CharField(max_length=255, blank=True, null=True) images = models.ManyToManyField("files.Image", related_name="project_tabs") def get_related_project(self) -> Project: @@ -1069,7 +1060,7 @@ class ProjectTabItem(HasAutoTranslatedFields, ProjectRelated, models.Model): "projects.ProjectTab", on_delete=models.CASCADE, related_name="items" ) title = models.CharField(max_length=255) - content = models.TextField(blank=True) + content = models.TextField(blank=True, null=True) images = models.ManyToManyField("files.Image", related_name="project_tab_items") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index ee612752..88afbeb8 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -50,7 +50,6 @@ ProjectCategoryOrganizationError, ProjectMessageReplyOnReplyError, ProjectMessageReplyToSelfError, - ProjectTabChangeTypeError, ProjectWithNoOrganizationError, RemoveLastProjectOwnerError, ) @@ -914,18 +913,12 @@ class Meta: model = ProjectTab read_only_fields = ["id", "modules"] fields = read_only_fields + [ - "type", "title", "description", "icon", "images", ] - def validate_type(self, value: str): - if self.instance and self.instance.type != value: - raise ProjectTabChangeTypeError - return value - def get_string_images_kwargs( self, instance: ProjectTab, field_name: str, *args: Any, **kwargs: Any ) -> dict[str, Any]: diff --git a/apps/projects/tests/views/test_project_tab.py b/apps/projects/tests/views/test_project_tab.py index aecceebd..970cd72a 100644 --- a/apps/projects/tests/views/test_project_tab.py +++ b/apps/projects/tests/views/test_project_tab.py @@ -1,12 +1,8 @@ -import random - from django.urls import reverse from faker import Faker from parameterized import parameterized from rest_framework import status -from apps.accounts.factories import UserFactory -from apps.accounts.utils import get_superadmins_group from apps.commons.test import JwtAPITestCase, TestRoles from apps.organizations.factories import OrganizationFactory from apps.projects.factories import ProjectFactory, ProjectTabFactory @@ -42,7 +38,6 @@ def test_create_project_tab(self, role, expected_code): user = self.get_parameterized_test_user(role, instances=[self.project]) self.client.force_authenticate(user) payload = { - "type": random.choice(ProjectTab.TabType.values), # nosec "icon": faker.word(), "title": faker.sentence(), "description": faker.text(), @@ -55,7 +50,6 @@ def test_create_project_tab(self, role, expected_code): content = response.json() tab = ProjectTab.objects.get(id=content["id"]) self.assertEqual(tab.project.id, self.project.id) - self.assertEqual(content["type"], payload["type"]) self.assertEqual(content["icon"], payload["icon"]) self.assertEqual(content["title"], payload["title"]) self.assertEqual(content["description"], payload["description"]) @@ -195,37 +189,3 @@ def test_delete_project_tab(self, role, expected_code): self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_204_NO_CONTENT: self.assertFalse(ProjectTab.objects.filter(id=tab.id).exists()) - - -class ValidateProjectTabTestCase(JwtAPITestCase): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.organization = OrganizationFactory() - cls.project = ProjectFactory( - publication_status=Project.PublicationStatus.PUBLIC, - organizations=[cls.organization], - ) - cls.superadmin = UserFactory(groups=[get_superadmins_group()]) - - def test_update_tab_type(self): - self.client.force_authenticate(self.superadmin) - tab = ProjectTabFactory(project=self.project, type=ProjectTab.TabType.TEXT) - payload = {"type": ProjectTab.TabType.TEXT} - response = self.client.patch( - reverse("ProjectTab-detail", args=(self.project.id, tab.id)), - data=payload, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json() - self.assertEqual(content["type"], payload["type"]) - payload = {"type": ProjectTab.TabType.BLOG} - response = self.client.patch( - reverse("ProjectTab-detail", args=(self.project.id, tab.id)), - data=payload, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertApiValidationError( - response, - {"type": ["You cannot change the type of a project's tab"]}, - ) diff --git a/services/translator/tests/views/test_project_tab_item_translated_fields.py b/services/translator/tests/views/test_project_tab_item_translated_fields.py index bba42ede..3b3de840 100644 --- a/services/translator/tests/views/test_project_tab_item_translated_fields.py +++ b/services/translator/tests/views/test_project_tab_item_translated_fields.py @@ -12,7 +12,7 @@ ProjectTabFactory, ProjectTabItemFactory, ) -from apps.projects.models import ProjectTab, ProjectTabItem +from apps.projects.models import ProjectTabItem from services.translator.models import AutoTranslatedField faker = Faker() @@ -25,7 +25,7 @@ def setUpTestData(cls) -> None: cls.organization = OrganizationFactory() cls.project = ProjectFactory(organizations=[cls.organization]) cls.project_tab = ProjectTabFactory( - project=cls.project, type=ProjectTab.TabType.BLOG + project=cls.project, ) cls.superadmin = UserFactory(groups=[get_superadmins_group()]) cls.content_type = ContentType.objects.get_for_model(ProjectTabItem) diff --git a/services/translator/tests/views/test_project_tab_translated_fields.py b/services/translator/tests/views/test_project_tab_translated_fields.py index c0673b9e..3aba84b8 100644 --- a/services/translator/tests/views/test_project_tab_translated_fields.py +++ b/services/translator/tests/views/test_project_tab_translated_fields.py @@ -26,7 +26,6 @@ def setUpTestData(cls) -> None: def test_create_project_tab(self): self.client.force_authenticate(self.superadmin) payload = { - "type": ProjectTab.TabType.BLOG, "icon": faker.word(), "title": faker.word(), "description": faker.word(), From c391b28c269581108234849cb432a488aeb9f4ec Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 15 Jun 2026 15:31:38 +0200 Subject: [PATCH 39/42] feat: add filter viewset --- apps/projects/exceptions.py | 6 +++++ apps/projects/filters.py | 11 +++++++- .../migrations/0005_projecttab_type.py | 25 +++++++++++++++++++ .../0006_projecttab_show_preview.py | 18 +++++++++++++ apps/projects/models.py | 12 ++++++++- apps/projects/serializers.py | 10 +++++++- apps/projects/views.py | 8 +++++- 7 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 apps/projects/migrations/0005_projecttab_type.py create mode 100644 apps/projects/migrations/0006_projecttab_show_preview.py diff --git a/apps/projects/exceptions.py b/apps/projects/exceptions.py index df055953..c17bbb0b 100644 --- a/apps/projects/exceptions.py +++ b/apps/projects/exceptions.py @@ -127,3 +127,9 @@ class ProjectMessageReplyToSelfError(ValidationError): status_code = status.HTTP_400_BAD_REQUEST default_detail = _("A message cannot be a reply to itself") default_code = "project_message_reply_to_self_error" + + +class ProjectTabChangeTypeError(ValidationError): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _("You cannot change the type of a project's tab") + default_code = "project_tab_change_type_error" diff --git a/apps/projects/filters.py b/apps/projects/filters.py index 8a1f5ef0..1ab2e966 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -10,7 +10,7 @@ ) from apps.organizations.utils import get_below_hierarchy_codes -from .models import Location, Project +from .models import Location, Project, ProjectTab class ProjectFilterMixin(filters.FilterSet): @@ -118,3 +118,12 @@ class ProjectGroupsFilter(filters.FilterSet): class Meta: model = PeopleGroup fields = ("role",) + + +class ProjectTabFilter(filters.FilterSet): + type = filters.CharFilter() + show_preview = filters.BooleanFilter() + + class Meta: + model = ProjectTab + fields = ("type", "show_preview") diff --git a/apps/projects/migrations/0005_projecttab_type.py b/apps/projects/migrations/0005_projecttab_type.py new file mode 100644 index 00000000..e7139594 --- /dev/null +++ b/apps/projects/migrations/0005_projecttab_type.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.4 on 2026-06-12 09:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "projects", + "0004_remove_projecttab_type_alter_projecttab_description_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="projecttab", + name="type", + field=models.CharField( + choices=[("text", "Text"), ("blog", "Blog")], + default="text", + max_length=32, + ), + ), + ] diff --git a/apps/projects/migrations/0006_projecttab_show_preview.py b/apps/projects/migrations/0006_projecttab_show_preview.py new file mode 100644 index 00000000..34e4a422 --- /dev/null +++ b/apps/projects/migrations/0006_projecttab_show_preview.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-06-15 12:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0005_projecttab_type"), + ] + + operations = [ + migrations.AddField( + model_name="projecttab", + name="show_preview", + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 20269509..8ae2330d 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -14,6 +14,7 @@ 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 @@ -30,7 +31,6 @@ 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 @@ -1019,15 +1019,25 @@ class ProjectTab( auto_translated_fields: list[str] = ["title", "html:description"] + class TabType(models.TextChoices): + """Type of a tab.""" + + TEXT = "text" + BLOG = "blog" + project = models.ForeignKey( "projects.Project", on_delete=models.CASCADE, related_name="additional_tabs", ) + type = models.CharField( + max_length=32, choices=TabType.choices, default=TabType.TEXT + ) title = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) icon = models.CharField(max_length=255, blank=True, null=True) images = models.ManyToManyField("files.Image", related_name="project_tabs") + show_preview = models.BooleanField(default=True) def get_related_project(self) -> Project: """Return the projects related to this model.""" diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 88afbeb8..e19a1431 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -7,6 +7,7 @@ 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 ( @@ -40,7 +41,6 @@ ) from apps.skills.models import Tag from apps.skills.serializers import TagRelatedField -from services.translator.serializers import auto_translated from .exceptions import ( AddProjectToOrganizationPermissionError, @@ -50,6 +50,7 @@ ProjectCategoryOrganizationError, ProjectMessageReplyOnReplyError, ProjectMessageReplyToSelfError, + ProjectTabChangeTypeError, ProjectWithNoOrganizationError, RemoveLastProjectOwnerError, ) @@ -913,12 +914,19 @@ class Meta: model = ProjectTab read_only_fields = ["id", "modules"] fields = read_only_fields + [ + "type", "title", "description", "icon", "images", + "show_preview", ] + def validate_type(self, value: str): + if self.instance and self.instance.type != value: + raise ProjectTabChangeTypeError + return value + def get_string_images_kwargs( self, instance: ProjectTab, field_name: str, *args: Any, **kwargs: Any ) -> dict[str, Any]: diff --git a/apps/projects/views.py b/apps/projects/views.py index 60c1a91f..be04fa72 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -50,7 +50,12 @@ OrganizationsParameterMissing, ) -from .filters import ProjectFilter, ProjectGroupsFilter, ProjectMembersFilter +from .filters import ( + ProjectFilter, + ProjectGroupsFilter, + ProjectMembersFilter, + ProjectTabFilter, +) from .models import ( BlogEntry, LinkedProject, @@ -796,6 +801,7 @@ class ProjectTabViewset(NestedProjectViewMixins, viewsets.ModelViewSet): serializer_class = ProjectTabSerializer filter_backends = [DjangoFilterBackend] + filterset_class = ProjectTabFilter lookup_field = "id" lookup_value_regex = "[^/]+" permission_classes = [ From 264a827fc5b968067b6e5bc1b436e1ed3e98a997 Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 15 Jun 2026 15:54:08 +0200 Subject: [PATCH 40/42] cleanup --- apps/projects/factories.py | 1 + apps/projects/filters.py | 20 +++++++++- ..._alter_projecttab_description_and_more.py} | 7 ++-- .../migrations/0005_projecttab_type.py | 25 ------------ .../0006_projecttab_show_preview.py | 18 --------- apps/projects/models.py | 2 +- apps/projects/serializers.py | 2 +- apps/projects/tests/views/test_project_tab.py | 40 +++++++++++++++++++ apps/projects/views.py | 5 ++- 9 files changed, 69 insertions(+), 51 deletions(-) rename apps/projects/migrations/{0004_remove_projecttab_type_alter_projecttab_description_and_more.py => 0004_projecttab_show_preview_alter_projecttab_description_and_more.py} (82%) delete mode 100644 apps/projects/migrations/0005_projecttab_type.py delete mode 100644 apps/projects/migrations/0006_projecttab_show_preview.py diff --git a/apps/projects/factories.py b/apps/projects/factories.py index 09468817..e5b0ad88 100644 --- a/apps/projects/factories.py +++ b/apps/projects/factories.py @@ -168,6 +168,7 @@ class ProjectTabFactory(factory.django.DjangoModelFactory): icon = factory.Faker("word") title = factory.Faker("sentence") description = factory.Faker("text") + type = FuzzyChoice(ProjectTab.TabType.choices, getter=lambda c: c[0]) class Meta: model = ProjectTab diff --git a/apps/projects/filters.py b/apps/projects/filters.py index 1ab2e966..5e81d166 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -10,7 +10,7 @@ ) from apps.organizations.utils import get_below_hierarchy_codes -from .models import Location, Project, ProjectTab +from .models import Location, Project, ProjectTab, ProjectTabItem class ProjectFilterMixin(filters.FilterSet): @@ -121,9 +121,25 @@ class Meta: class ProjectTabFilter(filters.FilterSet): - type = filters.CharFilter() + type = filters.CharFilter() # noqa: A003 show_preview = filters.BooleanFilter() class Meta: model = ProjectTab fields = ("type", "show_preview") + + +class ProjectTabItemFilter(filters.FilterSet): + from_date = filters.CharFilter(method="range_filter_from", label="form_date") + to_date = filters.CharFilter(method="range_filter_to", label="to_date") + + class Meta: + model = ProjectTabItem + fields = ("from_date", "to_date") + + def range_filter_from(self, queryset, name, value): + return queryset.filter(created_at__gte=value) + + def range_filter_to(self, queryset, name, value): + # same above but with start_date + return queryset.filter(created_at__lte=value) diff --git a/apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py b/apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py similarity index 82% rename from apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py rename to apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py index 0f17e204..097e84cc 100644 --- a/apps/projects/migrations/0004_remove_projecttab_type_alter_projecttab_description_and_more.py +++ b/apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.4 on 2026-06-11 14:15 +# Generated by Django 6.0.4 on 2026-06-15 13:44 from django.db import migrations, models @@ -10,9 +10,10 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( + migrations.AddField( model_name="projecttab", - name="type", + name="show_preview", + field=models.BooleanField(default=True), ), migrations.AlterField( model_name="projecttab", diff --git a/apps/projects/migrations/0005_projecttab_type.py b/apps/projects/migrations/0005_projecttab_type.py deleted file mode 100644 index e7139594..00000000 --- a/apps/projects/migrations/0005_projecttab_type.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 6.0.4 on 2026-06-12 09:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "projects", - "0004_remove_projecttab_type_alter_projecttab_description_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="projecttab", - name="type", - field=models.CharField( - choices=[("text", "Text"), ("blog", "Blog")], - default="text", - max_length=32, - ), - ), - ] diff --git a/apps/projects/migrations/0006_projecttab_show_preview.py b/apps/projects/migrations/0006_projecttab_show_preview.py deleted file mode 100644 index 34e4a422..00000000 --- a/apps/projects/migrations/0006_projecttab_show_preview.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.4 on 2026-06-15 12:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("projects", "0005_projecttab_type"), - ] - - operations = [ - migrations.AddField( - model_name="projecttab", - name="show_preview", - field=models.BooleanField(default=True), - ), - ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 8ae2330d..43b5918c 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 @@ -31,6 +30,7 @@ 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 diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index e19a1431..d79c4959 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, diff --git a/apps/projects/tests/views/test_project_tab.py b/apps/projects/tests/views/test_project_tab.py index 970cd72a..aecceebd 100644 --- a/apps/projects/tests/views/test_project_tab.py +++ b/apps/projects/tests/views/test_project_tab.py @@ -1,8 +1,12 @@ +import random + from django.urls import reverse from faker import Faker from parameterized import parameterized from rest_framework import status +from apps.accounts.factories import UserFactory +from apps.accounts.utils import get_superadmins_group from apps.commons.test import JwtAPITestCase, TestRoles from apps.organizations.factories import OrganizationFactory from apps.projects.factories import ProjectFactory, ProjectTabFactory @@ -38,6 +42,7 @@ def test_create_project_tab(self, role, expected_code): user = self.get_parameterized_test_user(role, instances=[self.project]) self.client.force_authenticate(user) payload = { + "type": random.choice(ProjectTab.TabType.values), # nosec "icon": faker.word(), "title": faker.sentence(), "description": faker.text(), @@ -50,6 +55,7 @@ def test_create_project_tab(self, role, expected_code): content = response.json() tab = ProjectTab.objects.get(id=content["id"]) self.assertEqual(tab.project.id, self.project.id) + self.assertEqual(content["type"], payload["type"]) self.assertEqual(content["icon"], payload["icon"]) self.assertEqual(content["title"], payload["title"]) self.assertEqual(content["description"], payload["description"]) @@ -189,3 +195,37 @@ def test_delete_project_tab(self, role, expected_code): self.assertEqual(response.status_code, expected_code) if expected_code == status.HTTP_204_NO_CONTENT: self.assertFalse(ProjectTab.objects.filter(id=tab.id).exists()) + + +class ValidateProjectTabTestCase(JwtAPITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.organization = OrganizationFactory() + cls.project = ProjectFactory( + publication_status=Project.PublicationStatus.PUBLIC, + organizations=[cls.organization], + ) + cls.superadmin = UserFactory(groups=[get_superadmins_group()]) + + def test_update_tab_type(self): + self.client.force_authenticate(self.superadmin) + tab = ProjectTabFactory(project=self.project, type=ProjectTab.TabType.TEXT) + payload = {"type": ProjectTab.TabType.TEXT} + response = self.client.patch( + reverse("ProjectTab-detail", args=(self.project.id, tab.id)), + data=payload, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + self.assertEqual(content["type"], payload["type"]) + payload = {"type": ProjectTab.TabType.BLOG} + response = self.client.patch( + reverse("ProjectTab-detail", args=(self.project.id, tab.id)), + data=payload, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertApiValidationError( + response, + {"type": ["You cannot change the type of a project's tab"]}, + ) diff --git a/apps/projects/views.py b/apps/projects/views.py index be04fa72..2a144e27 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -55,6 +55,7 @@ ProjectGroupsFilter, ProjectMembersFilter, ProjectTabFilter, + ProjectTabItemFilter, ) from .models import ( BlogEntry, @@ -865,7 +866,9 @@ class ProjectTabItemViewset( """Project tabs.""" serializer_class = ProjectTabItemSerializer - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ProjectTabItemFilter + ordering = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[^/]+" permission_classes = [ From d4e551b1579ffba3d5b08e1d6d268994b44e1d73 Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 22 Jun 2026 11:50:59 +0200 Subject: [PATCH 41/42] revert: test type projectab --- .../tests/views/test_project_tab_item_translated_fields.py | 4 ++-- .../tests/views/test_project_tab_translated_fields.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/translator/tests/views/test_project_tab_item_translated_fields.py b/services/translator/tests/views/test_project_tab_item_translated_fields.py index 3b3de840..bba42ede 100644 --- a/services/translator/tests/views/test_project_tab_item_translated_fields.py +++ b/services/translator/tests/views/test_project_tab_item_translated_fields.py @@ -12,7 +12,7 @@ ProjectTabFactory, ProjectTabItemFactory, ) -from apps.projects.models import ProjectTabItem +from apps.projects.models import ProjectTab, ProjectTabItem from services.translator.models import AutoTranslatedField faker = Faker() @@ -25,7 +25,7 @@ def setUpTestData(cls) -> None: cls.organization = OrganizationFactory() cls.project = ProjectFactory(organizations=[cls.organization]) cls.project_tab = ProjectTabFactory( - project=cls.project, + project=cls.project, type=ProjectTab.TabType.BLOG ) cls.superadmin = UserFactory(groups=[get_superadmins_group()]) cls.content_type = ContentType.objects.get_for_model(ProjectTabItem) diff --git a/services/translator/tests/views/test_project_tab_translated_fields.py b/services/translator/tests/views/test_project_tab_translated_fields.py index 3aba84b8..c0673b9e 100644 --- a/services/translator/tests/views/test_project_tab_translated_fields.py +++ b/services/translator/tests/views/test_project_tab_translated_fields.py @@ -26,6 +26,7 @@ def setUpTestData(cls) -> None: def test_create_project_tab(self): self.client.force_authenticate(self.superadmin) payload = { + "type": ProjectTab.TabType.BLOG, "icon": faker.word(), "title": faker.word(), "description": faker.word(), From 541ceea78e7af9cf545d08bf2b98150aba8728ce Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 22 Jun 2026 15:22:00 +0200 Subject: [PATCH 42/42] test: fix owner --- apps/projects/tests/views/test_project_tab.py | 9 ++++++--- apps/projects/tests/views/test_project_tab_image.py | 6 ++++-- apps/projects/tests/views/test_project_tab_item.py | 9 ++++++--- apps/projects/tests/views/test_project_tab_item_image.py | 6 ++++-- apps/projects/views.py | 2 +- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/projects/tests/views/test_project_tab.py b/apps/projects/tests/views/test_project_tab.py index aecceebd..b7e6711b 100644 --- a/apps/projects/tests/views/test_project_tab.py +++ b/apps/projects/tests/views/test_project_tab.py @@ -109,13 +109,16 @@ def test_retrieve_project_tab(self, role, retrieved_tabs): user = self.get_parameterized_test_user(role, instances=[project]) self.client.force_authenticate(user) response = self.client.get(reverse("ProjectTab-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_tabs: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], tab.id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class UpdateProjectTabTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_image.py b/apps/projects/tests/views/test_project_tab_image.py index 4b6a9c36..2fe00061 100644 --- a/apps/projects/tests/views/test_project_tab_image.py +++ b/apps/projects/tests/views/test_project_tab_image.py @@ -50,7 +50,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")), @@ -73,7 +72,10 @@ def test_retrieve_project_tab_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 CreateProjectTabImageTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_item.py b/apps/projects/tests/views/test_project_tab_item.py index d8ce8dcf..4d25244d 100644 --- a/apps/projects/tests/views/test_project_tab_item.py +++ b/apps/projects/tests/views/test_project_tab_item.py @@ -112,16 +112,19 @@ def test_retrieve_project_tab_items(self, role, retrieved_items): response = self.client.get( reverse("ProjectTabItem-list", args=(project.id, tab.id)) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_items: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 2) self.assertSetEqual( {item["id"] for item in content}, {item.id for item in item} ) self.assertGreater(content[0]["created_at"], content[1]["created_at"]) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class UpdateProjectTabItemTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_item_image.py b/apps/projects/tests/views/test_project_tab_item_image.py index 5f0c47ff..dfb7f0bd 100644 --- a/apps/projects/tests/views/test_project_tab_item_image.py +++ b/apps/projects/tests/views/test_project_tab_item_image.py @@ -59,7 +59,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")), @@ -85,7 +84,10 @@ def test_retrieve_tab_item_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 CreateProjectTabItemImageTestCase(JwtAPITestCase): diff --git a/apps/projects/views.py b/apps/projects/views.py index 73d21480..36c38edd 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -858,7 +858,7 @@ class ProjectTabItemViewset( serializer_class = ProjectTabItemSerializer filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_class = ProjectTabItemFilter - ordering = ("created_at", "updated_at") + ordering_fields = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[^/]+" permission_classes = [