From 91270ad2ba0113dfc4bed49bac0a2fc5f71eeb2b Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 9 Feb 2026 21:21:07 +0100 Subject: [PATCH 01/33] refactor: embeding --- apps/commons/mixins.py | 21 +++++++++++++++++++++ services/crisalid/models.py | 25 ++++--------------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 84ef04d5..6882e0a2 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -408,3 +408,24 @@ def get_slug(self) -> str: if self.get_id_field_name(slug) != "slug": slug = f"{self.slug_prefix}-{slug}" return slug + + + +class HasEmbending: + def vectorize(self): + if not getattr(self, "embedding", None): + model_embedding = type(self.embedding) + self.embedding = model_embedding(item=self) + self.embedding.save() + self.embedding.vectorize() + + def similars(self, threshold: float = 0.15) -> QuerySet[Self]: + """return similars documents""" + if getattr(self, "embedding", None): + vector = self.embedding.embedding + model_embedding = type(self.embedding) + queryset = type(self).objects.all() + return model_embedding.vector_search(vector, queryset, threshold).exclude( + pk=self.pk + ) + return type(self).objects.all() diff --git a/services/crisalid/models.py b/services/crisalid/models.py index 965cf3c5..e3267483 100644 --- a/services/crisalid/models.py +++ b/services/crisalid/models.py @@ -5,10 +5,9 @@ from django.db import models from django.db.models.functions import Lower -from apps.commons.mixins import OrganizationRelated +from apps.commons.mixins import HasEmbending, OrganizationRelated from apps.organizations.models import Organization from services.crisalid import relators -from services.mistral.models import DocumentEmbedding from services.translator.mixins import HasAutoTranslatedFields from .manager import CrisalidQuerySet, DocumentQuerySet @@ -121,7 +120,9 @@ class Meta: ] -class Document(OrganizationRelated, HasAutoTranslatedFields, CrisalidDataModel): +class Document( + HasEmbending, OrganizationRelated, HasAutoTranslatedFields, CrisalidDataModel +): """ Represents a research publicaiton (or 'document') in the Crisalid system. """ @@ -217,24 +218,6 @@ def document_type_centralized(self) -> list[str]: return vals return [self.document_type] - def vectorize(self): - if not getattr(self, "embedding", None): - self.embedding = DocumentEmbedding(item=self) - self.embedding.save() - self.embedding.vectorize() - - def similars(self, threshold: float = 0.15) -> DocumentQuerySet: - """return similars documents""" - if getattr(self, "embedding", None): - vector = self.embedding.embedding - queryset = Document.objects.all() - return ( - DocumentEmbedding.vector_search(vector, queryset, threshold) - .filter(document_type__in=self.document_type_centralized) - .exclude(pk=self.pk) - ) - return Document.objects.none() - def save(self, *ar, **kw): md = super().save(*ar, **kw) # when we update models , re-calculate vectorize From cbcc15e619f77d44b350e9eacaefc8703d3c029a Mon Sep 17 00:00:00 2001 From: rgermain Date: Mon, 9 Feb 2026 21:57:27 +0100 Subject: [PATCH 02/33] feat(group): add groups modules --- apps/accounts/admin.py | 15 ++++--- apps/accounts/models.py | 6 +++ apps/accounts/serializers.py | 74 +++++++++++++++++++------------ apps/accounts/views.py | 85 +++++++++++++++++++----------------- apps/commons/mixins.py | 8 ++++ apps/commons/views.py | 8 ++++ apps/modules/__init__.py | 3 ++ apps/modules/base.py | 52 ++++++++++++++++++++++ apps/modules/group.py | 79 +++++++++++++++++++++++++++++++++ apps/modules/serializers.py | 13 ++++++ services/mistral/models.py | 26 +++++++++++ services/mistral/tasks.py | 9 +++- 12 files changed, 302 insertions(+), 76 deletions(-) create mode 100644 apps/modules/__init__.py create mode 100644 apps/modules/base.py create mode 100644 apps/modules/group.py create mode 100644 apps/modules/serializers.py diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index e429c7eb..a463f0ec 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -20,6 +20,7 @@ from .utils import get_group_permissions +@admin.register(ProjectUser) class UserAdmin(TranslateObjectAdminMixin, ExportActionMixin, RoleBasedAccessAdmin): resource_classes = [UserResource] @@ -88,6 +89,10 @@ class Meta: verbose_name = "User" +admin.unregister(Group) + + +@admin.register(Group) class GroupAdmin(admin.ModelAdmin): class GroupUsersInline(admin.TabularInline): model = Group.users.through @@ -156,6 +161,7 @@ def permissions_representations(self, instance: Group) -> str: return "- " + "\n- ".join(get_group_permissions(instance)) +@admin.register(PeopleGroup) class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): list_display = ("id", "name", "organization", "email") search_fields = ("name", "email", "id") @@ -163,13 +169,8 @@ class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): list_filter = ("organization",) + +@admin.register(Permission) class PermissionAdmin(admin.ModelAdmin): list_display = ("name", "codename", "content_type") search_fields = ("name", "codename", "content_type__model") - - -admin.site.unregister(Group) -admin.site.register(Group, GroupAdmin) -admin.site.register(PeopleGroup, PeopleGroupAdmin) -admin.site.register(ProjectUser, UserAdmin) -admin.site.register(Permission, PermissionAdmin) diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 2ebe2211..f4f6061d 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -25,6 +25,8 @@ ) from apps.commons.enums import SDG, Language from apps.commons.mixins import ( + HasEmbending, + HasModulesRelated, HasMultipleIDs, HasOwner, HasPermissionsSetup, @@ -41,6 +43,8 @@ class PeopleGroup( + HasEmbending, + HasModulesRelated, HasAutoTranslatedFields, HasMultipleIDs, HasPermissionsSetup, @@ -144,6 +148,8 @@ class PublicationStatus(models.TextChoices): updated_at = models.DateTimeField(auto_now=True) permissions_up_to_date = models.BooleanField(default=False) + tags = models.ManyToManyField("skills.Tag", related_name="people_groups") + def __str__(self) -> str: return str(self.name) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 41e79e68..42195db4 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -19,11 +19,12 @@ from apps.commons.serializers import StringsImagesSerializer from apps.files.models import Image from apps.files.serializers import ImageSerializer +from apps.modules.serializers import ModulesSerializers from apps.notifications.models import Notification from apps.organizations.models import Organization from apps.projects.models import Project from apps.skills.models import Skill -from apps.skills.serializers import SkillLightSerializer +from apps.skills.serializers import SkillLightSerializer, TagRelatedField from services.crisalid.serializers import ResearcherSerializerLight from services.translator.serializers import AutoTranslatedModelSerializer @@ -238,10 +239,9 @@ class Meta: class PeopleGroupLightSerializer( - AutoTranslatedModelSerializer, serializers.ModelSerializer + ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer ): header_image = ImageSerializer(read_only=True) - members_count = serializers.SerializerMethodField() roles = serializers.SlugRelatedField( many=True, slug_field="name", @@ -250,9 +250,6 @@ class PeopleGroupLightSerializer( ) organization = serializers.SlugRelatedField(read_only=True, slug_field="code") - def get_members_count(self, group: PeopleGroup) -> int: - return group.get_all_members().count() - class Meta: model = PeopleGroup read_only_fields = ["organization", "is_root", "publication_status"] @@ -264,12 +261,26 @@ class Meta: "short_description", "email", "header_image", - "members_count", "roles", + "modules", ] + def get_modules(self, people_group: PeopleGroup): + context = self.context + request = context.get("request") + + modules_manager = people_group.get_related_module() + modules = modules_manager(people_group, request.user) + + # return only members and subgroups coun ( for card ) + return { + "members": modules.members().count(), + "subgroups": modules.subgroups().count(), + } + class PeopleGroupHierarchySerializer( + ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer, ): @@ -292,15 +303,27 @@ class Meta: "header_image", "children", "roles", + "modules", ] fields = read_only_fields - def get_children( - self, people_group: PeopleGroup - ) -> List[Dict[str, Union[str, int]]]: + def get_modules(self, people_group: PeopleGroup): + context = self.context + request = context.get("request") + + modules_manager = people_group.get_related_module() + modules = modules_manager(people_group, request.user) + + return { + "members": modules.members().count(), + "subgroups": modules.subgroups().count(), + } + + def get_children(self, people_group: PeopleGroup) -> list[dict[str, str | int]]: context = self.context request = context.get("request") mapping = context.get("mapping") + if not mapping: base_queryset = request.user.get_people_group_queryset().filter( organization=people_group.organization @@ -402,7 +425,10 @@ def create(self, validated_data): class PeopleGroupSerializer( - StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer + ModulesSerializers, + StringsImagesSerializer, + AutoTranslatedModelSerializer, + serializers.ModelSerializer, ): string_images_forbid_fields: List[str] = [ @@ -415,7 +441,6 @@ class PeopleGroupSerializer( slug_field="code", queryset=Organization.objects.all() ) hierarchy = serializers.SerializerMethodField() - children = serializers.SerializerMethodField() parent = serializers.PrimaryKeyRelatedField( queryset=PeopleGroup.objects.all(), required=False, @@ -434,6 +459,12 @@ class PeopleGroupSerializer( 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), + required=False, + ) def get_hierarchy(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: request = self.context.get("request") @@ -447,20 +478,7 @@ def get_hierarchy(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: ) return [{"order": i, **h} for i, h in enumerate(hierarchy[::-1])] - def get_children(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: - request = self.context.get("request") - queryset = ( - request.user.get_people_group_queryset() - .select_related("organization") - .filter(parent=obj) - .order_by("name") - .distinct() - ) - return PeopleGroupSuperLightSerializer( - queryset, many=True, context=self.context - ).data - - def validate_featured_projects(self, projects: List[Project]) -> List[Project]: + 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 @@ -527,7 +545,7 @@ def save(self, **kwargs): class Meta: model = PeopleGroup - read_only_fields = ["is_root", "slug"] + read_only_fields = ["is_root", "slug", "modules"] fields = read_only_fields + [ "id", "name", @@ -541,6 +559,8 @@ class Meta: "header_image", "logo_image", "roles", + "sdgs", + "tags", "publication_status", "team", "featured_projects", diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 7abd38fe..00b77832 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -686,27 +686,10 @@ def remove_member(self, request, *args, **kwargs): ) def member(self, request, *args, **kwargs): group = self.get_object() - managers_ids = group.managers.all().values_list("id", flat=True) - leaders_ids = group.leaders.all().values_list("id", flat=True) - skills_prefetch = Prefetch( - "skills", queryset=Skill.objects.select_related("tag") - ) - queryset = ( - group.get_all_members() - .distinct() - .annotate( - is_leader=Case( - When(id__in=leaders_ids, then=True), default=Value(False) - ) - ) - .annotate( - is_manager=Case( - When(id__in=managers_ids, then=True), default=Value(False) - ) - ) - .order_by("-is_leader", "-is_manager") - .prefetch_related(skills_prefetch, "groups") - ) + + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.members() page = self.paginate_queryset(queryset) if page is not None: @@ -794,26 +777,10 @@ def remove_featured_project(self, request, *args, **kwargs): ) def project(self, request, *args, **kwargs): group = self.get_object() - group_projects_ids = ( - Project.objects.filter(groups__people_groups=group) - .distinct() - .values_list("id", flat=True) - ) - queryset = ( - self.request.user.get_project_queryset() - .filter(Q(groups__people_groups=group) | Q(people_groups=group)) - .annotate( - is_group_project=Case( - When(id__in=group_projects_ids, then=True), default=Value(False) - ), - is_featured=Case( - When(people_groups=group, then=True), default=Value(False) - ), - ) - .distinct() - .order_by("-is_featured", "-is_group_project") - .prefetch_related("categories") - ) + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.featured_projects() + page = self.paginate_queryset(queryset) if page is not None: project_serializer = ProjectLightSerializer( @@ -841,6 +808,42 @@ def hierarchy(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) + @action( + detail=True, + methods=["GET"], + url_path="subgroups", + permission_classes=[ReadOnly], + ) + def subgroups(self, request, *args, **kwargs): + group = self.get_object() + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.subgroups() + + queryset_page = self.paginate_queryset(queryset) + data = self.serializer_class( + queryset_page, many=True, context={"request": request} + ) + return self.get_paginated_response(data.data) + + @action( + detail=True, + methods=["GET"], + url_path="similars", + permission_classes=[ReadOnly], + ) + def similars(self, request, *args, **kwargs): + group = self.get_object() + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.similars() + + queryset_page = self.paginate_queryset(queryset) + data = PeopleGroupLightSerializer( + queryset_page, many=True, context={"request": request} + ) + return self.get_paginated_response(data.data) + @extend_schema( parameters=[OpenApiParameter("people_group_id", str, OpenApiParameter.PATH)] diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 6882e0a2..8349207d 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -410,6 +410,14 @@ def get_slug(self) -> str: return slug +class HasModulesRelated: + """Mixins for related modules class""" + + def get_related_module(self): + from apps.modules.base import get_module + + return get_module(type(self)) + class HasEmbending: def vectorize(self): diff --git a/apps/commons/views.py b/apps/commons/views.py index 647ecb42..b463299a 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -3,6 +3,7 @@ from rest_framework.response import Response from rest_framework.settings import api_settings +from apps.accounts.models import PeopleGroup from apps.organizations.models import Organization from .mixins import HasMultipleIDs @@ -150,3 +151,10 @@ def initial(self, request, *args, **kwargs): ) super().initial(request, *args, **kwargs) + + +class NestedPeopleGroupViewMixins: + def initial(self, request, *args, **kwargs): + self.people_group = get_object_or_404(PeopleGroup, id=kwargs["people_group_id"]) + + super().initial(request, *args, **kwargs) diff --git a/apps/modules/__init__.py b/apps/modules/__init__.py new file mode 100644 index 00000000..98a20100 --- /dev/null +++ b/apps/modules/__init__.py @@ -0,0 +1,3 @@ +from .group import PeopleGroupModules + +__all__ = ["PeopleGroupModules"] diff --git a/apps/modules/base.py b/apps/modules/base.py new file mode 100644 index 00000000..16e72037 --- /dev/null +++ b/apps/modules/base.py @@ -0,0 +1,52 @@ +import inspect + +from django.db import models + + +class AbstractModules: + """abstract class for modules/queryset declarations""" + + def __init__(self, instance, /, user, **kw): + self.instance = instance + self.user = user + + def _items(self): + members = inspect.getmembers( + self, + predicate=inspect.ismethod, + ) + + for name, func in members: + # ignore private_method and "count" method (this method :D) + if name.startswith("_") or name in ("count",): + continue + + yield name, func + + def count(self): + modules = {} + for name, func in self._items(): + # func return queryset + modules[name] = func().count() + return modules + + +_modules: dict[models.Model] = {} + + +def register_module(model: models.Model): + """decorator to register modules assoiate on models + + :param model: _description_ + """ + + def _wrap(cls): + _modules[model] = cls + return cls + + return _wrap + + +def get_module(model: models.Model): + """get regisered module""" + return _modules[model] diff --git a/apps/modules/group.py b/apps/modules/group.py new file mode 100644 index 00000000..9aa15282 --- /dev/null +++ b/apps/modules/group.py @@ -0,0 +1,79 @@ +from django.db.models import Case, Prefetch, Q, QuerySet, Value, When + +from apps.accounts.models import PeopleGroup, ProjectUser +from apps.modules.base import AbstractModules, register_module +from apps.projects.models import Location, Project +from apps.skills.models import Skill +from services.crisalid.models import Document, DocumentTypeCentralized + + +@register_module(PeopleGroup) +class PeopleGroupModules(AbstractModules): + instance: PeopleGroup + + def members(self) -> QuerySet[ProjectUser]: + managers_ids = self.instance.managers.all().values_list("id", flat=True) + leaders_ids = self.instance.leaders.all().values_list("id", flat=True) + skills_prefetch = Prefetch( + "skills", queryset=Skill.objects.select_related("tag") + ) + return ( + self.instance.get_all_members() + .distinct() + .annotate( + is_leader=Case( + When(id__in=leaders_ids, then=True), default=Value(False) + ) + ) + .annotate( + is_manager=Case( + When(id__in=managers_ids, then=True), default=Value(False) + ) + ) + .order_by("-is_leader", "-is_manager") + .prefetch_related(skills_prefetch, "groups") + ) + + def featured_projects(self) -> QuerySet[Project]: + group_projects_ids = ( + Project.objects.filter(groups__people_groups=self.instance) + .distinct() + .values_list("id", flat=True) + ) + + return ( + self.user.get_project_queryset() + .filter( + Q(groups__people_groups=self.instance) | Q(people_groups=self.instance) + ) + .annotate( + is_group_project=Case( + When(id__in=group_projects_ids, then=True), default=Value(False) + ), + is_featured=Case( + When(people_groups=self.instance, then=True), default=Value(False) + ), + ) + .distinct() + .order_by("-is_featured", "-is_group_project") + .prefetch_related("categories") + ) + + def similars(self) -> QuerySet[PeopleGroup]: + return self.instance.similars() + + def subgroups(self) -> QuerySet[PeopleGroup]: + return self.instance.children.all() + + def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: + members_qs = self.members() + return Document.objects.filter( + document_type__in=documents_type, + contributors__user__in=members_qs, + ).distinct() + + def publications(self) -> QuerySet[Document]: + return self._documents(DocumentTypeCentralized.publications) + + def conferences(self) -> QuerySet[Document]: + return self._documents(DocumentTypeCentralized.conferences) diff --git a/apps/modules/serializers.py b/apps/modules/serializers.py new file mode 100644 index 00000000..f418361c --- /dev/null +++ b/apps/modules/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + + +class ModulesSerializers(serializers.ModelSerializer): + """Modules serializers to return how many elements is linked to objects""" + + modules = serializers.SerializerMethodField() + + def get_modules(self, instance): + request = self.context.get("request") + + modules_manager = instance.get_related_module() + return modules_manager(instance, user=request.user).count() diff --git a/services/mistral/models.py b/services/mistral/models.py index c7b67223..c2f09c1d 100644 --- a/services/mistral/models.py +++ b/services/mistral/models.py @@ -458,3 +458,29 @@ def set_embedding(self, *args, **kwargs) -> "DocumentEmbedding": self.prompt_hashcode = prompt_hashcode self.save() return self + + +class GroupEmbedding(MistralEmbedding): + item = models.OneToOneField( + "accounts.PeopleGroup", on_delete=models.CASCADE, related_name="embedding" + ) + + def get_fields(self) -> list[str]: + # TODO(remi): add more fields + return ( + self.item.name, + self.item.description, + ) + + def get_is_visible(self) -> bool: + return any(self.get_fields()) + + def set_embedding(self, *args, **kwargs) -> "DocumentEmbedding": + prompt = self.get_fields() + prompt_hashcode = self.hash_prompt(prompt) + if self.prompt_hashcode != prompt_hashcode: + prompt = "\n\n".join(prompt) + self.embedding = MistralService.get_embedding(prompt) + self.prompt_hashcode = prompt_hashcode + self.save() + return self diff --git a/services/mistral/tasks.py b/services/mistral/tasks.py index 60b794c0..929479ea 100644 --- a/services/mistral/tasks.py +++ b/services/mistral/tasks.py @@ -3,7 +3,13 @@ from apps.commons.utils import clear_memory from projects.celery import app -from .models import DocumentEmbedding, MistralEmbedding, ProjectEmbedding, UserEmbedding +from .models import ( + DocumentEmbedding, + GroupEmbedding, + MistralEmbedding, + ProjectEmbedding, + UserEmbedding, +) logger = logging.getLogger(__name__) @@ -32,3 +38,4 @@ def _vectorize_updated_objects(): _vectorize_objects(ProjectEmbedding) _vectorize_objects(UserEmbedding) _vectorize_objects(DocumentEmbedding) + _vectorize_objects(GroupEmbedding) From be896e8b7d6fa99f87a57a977fe1369a16d1f0e7 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:23:44 +0100 Subject: [PATCH 03/33] refactor: duplicate models --- apps/announcements/models.py | 11 --------- apps/commons/mixins.py | 20 ++++++++++++++--- apps/files/models.py | 43 ++++-------------------------------- apps/projects/models.py | 26 ++-------------------- 4 files changed, 23 insertions(+), 77 deletions(-) diff --git a/apps/announcements/models.py b/apps/announcements/models.py index f271e5de..ba4cf784 100644 --- a/apps/announcements/models.py +++ b/apps/announcements/models.py @@ -78,16 +78,5 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "Announcement": - return Announcement.objects.create( - project=project, - description=self.description, - title=self.title, - type=self.type, - status=self.status, - deadline=self.deadline, - is_remunerated=self.is_remunerated, - ) - def __str__(self): return str(self.title) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 8349207d..486b574a 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Self, Tuple from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType @@ -8,6 +8,7 @@ from django.utils.text import slugify from guardian.models import GroupObjectPermission from guardian.shortcuts import assign_perm, remove_perm +from copy import deepcopy from .models import GroupData @@ -268,8 +269,21 @@ class DuplicableModel: A model that can be duplicated. """ - def duplicate(self, *args, **kwargs) -> "DuplicableModel": - raise NotImplementedError() + def duplicate(self, **fields) -> Self: + """duplicate models elements, set new fields + + :return: new models + """ + + instance_copy = deepcopy(self) + instance_copy.pk = None + + for name, value in fields.items(): + setattr(instance_copy, name, value) + + instance_copy.save() + return instance_copy + class HasMultipleIDs: diff --git a/apps/files/models.py b/apps/files/models.py index b67010f8..50f80912 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -127,18 +127,6 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "AttachmentLink": - return AttachmentLink.objects.create( - project=project, - attachment_type=self.attachment_type, - category=self.category, - description=self.description, - preview_image_url=self.preview_image_url, - site_name=self.site_name, - site_url=self.site_url, - title=self.title, - ) - class OrganizationAttachmentFile( HasAutoTranslatedFields, OrganizationRelated, models.Model @@ -226,15 +214,7 @@ def duplicate(self, project: "Project") -> Optional["AttachmentFile"]: content=self.file.read(), content_type=f"application/{file_extension}", ) - return AttachmentFile.objects.create( - project=project, - attachment_type=self.attachment_type, - file=new_file, - mime=self.mime, - title=self.title, - description=self.description, - hashcode=self.hashcode, - ) + return super().duplicate(project=project, file=new_file) return None @@ -400,9 +380,7 @@ def get_related_project(self) -> Optional["Project"]: return queryset.first() return None - def duplicate( - self, owner: Optional["ProjectUser"] = None, upload_to: str = "" - ) -> Optional["Image"]: + def duplicate(self, upload_to: str = "", **fields) -> None | Self: with suppress(ResourceNotFoundError): file_path = self.file.name.split("/") file_name = file_path.pop() @@ -416,21 +394,8 @@ def duplicate( content=self.file.read(), content_type=f"image/{file_extension}", ) - image = Image( - name=self.name, - file=new_file, - height=self.height, - width=self.width, - natural_ratio=self.natural_ratio, - scale_x=self.scale_x, - scale_y=self.scale_y, - left=self.left, - top=self.top, - owner=owner or self.owner, - ) - image._upload_to = lambda instance, filename: upload_to - image.save() - return image + _upload_to = lambda instance, filename: upload_to + return super().duplicate(_upload_to=_upload_to, file=new_file, **fields) return None diff --git a/apps/projects/models.py b/apps/projects/models.py index 46053853..d350a480 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -554,18 +554,9 @@ def calculate_score(self) -> "ProjectScore": @transaction.atomic def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": header = self.header_image.duplicate(owner) if self.header_image else None - project = Project.objects.create( - title=self.title, + project = super().duplicate( header_image=header, - description=self.description, - purpose=self.purpose, - is_locked=self.is_locked, - is_shareable=self.is_shareable, publication_status=Project.PublicationStatus.PRIVATE, - life_status=self.life_status, - language=self.language, - sdgs=self.sdgs, - template=self.template, duplicated_from=self.id, ) project.setup_permissions(user=owner) @@ -768,11 +759,7 @@ def duplicate( initial_project: Optional["Project"] = None, owner: Optional["ProjectUser"] = None, ) -> "BlogEntry": - blog_entry = BlogEntry.objects.create( - project=project, - title=self.title, - content=self.content, - ) + blog_entry = super().duplicate(project=project) for image in self.images.all(): new_image = image.duplicate(owner) if new_image is not None: @@ -851,15 +838,6 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "Goal": - return Goal.objects.create( - project=project, - title=self.title, - description=self.description, - deadline_at=self.deadline_at, - status=self.status, - ) - class Location( HasAutoTranslatedFields, From 68c114020f10b321d273930230f2155e119f0ad0 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:25:03 +0100 Subject: [PATCH 04/33] missins copy --- apps/commons/mixins.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 486b574a..de9a002a 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,3 +1,4 @@ +from copy import deepcopy from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Self, Tuple from django.contrib.auth.models import Group, Permission @@ -8,7 +9,6 @@ from django.utils.text import slugify from guardian.models import GroupObjectPermission from guardian.shortcuts import assign_perm, remove_perm -from copy import deepcopy from .models import GroupData @@ -270,7 +270,7 @@ class DuplicableModel: """ def duplicate(self, **fields) -> Self: - """duplicate models elements, set new fields + """duplicate models elements, set new fields :return: new models """ @@ -280,12 +280,11 @@ def duplicate(self, **fields) -> Self: for name, value in fields.items(): setattr(instance_copy, name, value) - + instance_copy.save() return instance_copy - class HasMultipleIDs: """ This mixin handles models with multiple identifiers, including slugs. From 63b8686651dc123abaa0f9674ecc7bb35be37a67 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:26:27 +0100 Subject: [PATCH 05/33] missing dupli --- apps/projects/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/projects/models.py b/apps/projects/models.py index d350a480..1bb5604c 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -769,8 +769,6 @@ def duplicate( f"/v1/project/{identifier}/blog-entry-image/{image.pk}/", f"/v1/project/{project.pk}/blog-entry-image/{new_image.pk}/", ) - blog_entry.created_at = self.created_at - blog_entry.save() return blog_entry From 28f3ec8ac0854140262f0ec70bfdf15c92962b7c Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:34:43 +0100 Subject: [PATCH 06/33] feat: locations in people gorup --- apps/accounts/admin.py | 7 +++- apps/accounts/models.py | 13 +++++++- apps/accounts/serializers.py | 63 +++++++++++++++++++++++++++++++++--- apps/commons/serializers.py | 25 ++++++++++++++ apps/projects/models.py | 45 +++++++++++++++----------- apps/projects/serializers.py | 16 +++------ apps/projects/urls.py | 9 ++++-- apps/projects/views.py | 28 ++++++++++++---- 8 files changed, 159 insertions(+), 47 deletions(-) diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index a463f0ec..28d83b61 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -16,7 +16,7 @@ from services.keycloak.interface import KeycloakService from .exports import UserResource -from .models import PeopleGroup, ProjectUser +from .models import PeopleGroup, PeopleGroupLocation, ProjectUser from .utils import get_group_permissions @@ -169,6 +169,11 @@ class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): list_filter = ("organization",) +@admin.register(PeopleGroupLocation) +class PeopleGroupLocationAdmin(admin.ModelAdmin): + list_display = ("title", "description", "type") + search_fields = ("title", "description", "type") + @admin.register(Permission) class PermissionAdmin(admin.ModelAdmin): diff --git a/apps/accounts/models.py b/apps/accounts/models.py index f4f6061d..a8f40cea 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -35,13 +35,17 @@ from apps.commons.models import GroupData from apps.newsfeed.models import Event, Instruction, News from apps.organizations.models import Organization -from apps.projects.models import Project +from apps.projects.models import AbstractLocation, Project from services.keycloak.exceptions import RemoteKeycloakAccountNotFound from services.keycloak.interface import KeycloakService from services.keycloak.models import KeycloakAccount from services.translator.mixins import HasAutoTranslatedFields +class PeopleGroupLocation(AbstractLocation): + """base location for group""" + + class PeopleGroup( HasEmbending, HasModulesRelated, @@ -149,6 +153,13 @@ class PublicationStatus(models.TextChoices): permissions_up_to_date = models.BooleanField(default=False) tags = models.ManyToManyField("skills.Tag", related_name="people_groups") + location = models.OneToOneField( + PeopleGroupLocation, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="people_group", + ) def __str__(self) -> str: return str(self.name) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 42195db4..1cb454de 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -16,7 +16,10 @@ ) from apps.commons.mixins import HasPermissionsSetup from apps.commons.models import GroupData -from apps.commons.serializers import StringsImagesSerializer +from apps.commons.serializers import ( + BaseLocationSerializer, + StringsImagesSerializer, +) from apps.files.models import Image from apps.files.serializers import ImageSerializer from apps.modules.serializers import ModulesSerializers @@ -38,7 +41,13 @@ UserRoleAssignmentError, UserRolePermissionDeniedError, ) -from .models import AnonymousUser, PeopleGroup, PrivacySettings, ProjectUser +from .models import ( + AnonymousUser, + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, +) from .utils import get_default_group, get_instance_from_group @@ -227,6 +236,24 @@ def get_can_mentor_on(self, user: ProjectUser) -> List[Dict]: return [] +class PeopleGroupLocationSerializer(BaseLocationSerializer): + class Meta(BaseLocationSerializer.Meta): + model = PeopleGroupLocation + + +class PeopleGroupLocationRelated(serializers.RelatedField): + def get_queryset(self): + return PeopleGroupLocation.objects.all() + + def to_representation(self, instance: PeopleGroupLocation) -> dict: + return PeopleGroupLocationSerializer(instance=instance).data + + def to_internal_value(self, element: dict) -> PeopleGroupLocation: + if element.get("pk"): + return PeopleGroupLocation.objects.get(pk=element["pk"]) + return PeopleGroupLocation(**element) + + class PeopleGroupSuperLightSerializer( AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -465,6 +492,7 @@ class PeopleGroupSerializer( child=serializers.IntegerField(min_value=1, max_value=17), required=False, ) + location = PeopleGroupLocationRelated(required=False, allow_null=True) def get_hierarchy(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: request = self.context.get("request") @@ -526,6 +554,12 @@ def validate_parent(self, value): def create(self, validated_data): team = validated_data.pop("team", {}) featured_projects = validated_data.pop("featured_projects", []) + location = validated_data.pop("location", {}) + + if location: + location.save() + validated_data["id"] = location + people_group = super(PeopleGroupSerializer, self).create(validated_data) PeopleGroupAddTeamMembersSerializer().create( {"people_group": people_group, **team} @@ -538,10 +572,19 @@ def create(self, validated_data): def update(self, instance, validated_data): validated_data.pop("team", {}) validated_data.pop("featured_projects", []) - return super(PeopleGroupSerializer, self).update(instance, validated_data) + location = validated_data.pop("location") - def save(self, **kwargs): - return super().save(**kwargs) + if not location and getattr(instance, "location", None): + instance.location.delete() + validated_data["location"] = None + elif location: + location.save() + validated_data["location"] = location + + people_group = super(PeopleGroupSerializer, self).update( + instance, validated_data + ) + return people_group class Meta: model = PeopleGroup @@ -567,6 +610,16 @@ class Meta: ] +class LocationPeopleGroupSerializer( + AutoTranslatedModelSerializer, serializers.ModelSerializer +): + group = PeopleGroupSuperLightSerializer(source="people_group", read_only=True) + + class Meta: + model = PeopleGroupLocation + fields = "__all__" + + @extend_schema_serializer(exclude_fields=("roles",)) class UserSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer diff --git a/apps/commons/serializers.py b/apps/commons/serializers.py index c6679a7a..1d69b3f2 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -221,3 +221,28 @@ def save(self, **kwargs): return self.instance instance = super().save(**kwargs) return self.add_string_images_to_instance(instance, images) + + +class BaseLocationSerializer( + StringsImagesSerializer, + AutoTranslatedModelSerializer, + OrganizationRelatedSerializer, + serializers.ModelSerializer, +): + string_images_forbid_fields: list[str] = ["title", "description"] + + class Meta: + fields = [ + "id", + "title", + "description", + "lat", + "lng", + "type", + ] + + def get_related_organizations(self) -> list[Organization]: + """Retrieve the related organizations""" + if "project" in self.validated_data: + return self.validated_data["project"].get_related_organizations() + return [] diff --git a/apps/projects/models.py b/apps/projects/models.py index 1bb5604c..866d3d53 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -837,9 +837,8 @@ def get_related_project(self) -> Optional["Project"]: return self.project -class Location( +class AbstractLocation( HasAutoTranslatedFields, - ProjectRelated, DuplicableModel, models.Model, ): @@ -870,37 +869,45 @@ class LocationType(models.TextChoices): TEAM = "team" IMPACT = "impact" + ADDRESS = "address" + + class Meta: + abstract = True - project = models.ForeignKey( - Project, on_delete=models.CASCADE, related_name="locations" - ) title = models.CharField(max_length=255, blank=True) description = models.TextField(blank=True) lat = models.FloatField() lng = models.FloatField() type = models.CharField( - max_length=6, + max_length=10, choices=LocationType.choices, default=LocationType.TEAM, ) - def get_related_project(self) -> Optional["Project"]: - """Return the projects related to this model.""" - return self.project - def get_related_organizations(self) -> List["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() - def duplicate(self, project: "Project") -> "Location": - return Location.objects.create( - project=project, - title=self.title, - description=self.description, - lat=self.lat, - lng=self.lng, - type=self.type, - ) + +# TODO(remi): rename to ProjectLocation ? +class Location(ProjectRelated, AbstractLocation): + """A project location on Earth. + + Attributes + ---------- + id: Charfield + UUID4 used as the model's PK. + project: ForeignKey + Project at this location. + """ + + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="locations" + ) + + def get_related_project(self) -> Optional["Project"]: + """Return the projects related to this model.""" + return self.project class ProjectMessage( diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 47ca260c..587b9a21 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -18,6 +18,7 @@ ) from apps.commons.models import GroupData from apps.commons.serializers import ( + BaseLocationSerializer, OrganizationRelatedSerializer, ProjectRelatedSerializer, StringsImagesSerializer, @@ -182,13 +183,10 @@ class Meta: class LocationSerializer( - StringsImagesSerializer, - AutoTranslatedModelSerializer, - OrganizationRelatedSerializer, ProjectRelatedSerializer, - serializers.ModelSerializer, + BaseLocationSerializer, ): - string_images_forbid_fields: List[str] = ["title", "description"] + string_images_forbid_fields: list[str] = ["title", "description"] project = LocationProjectSerializer(read_only=True) project_id = serializers.PrimaryKeyRelatedField( @@ -209,13 +207,7 @@ class Meta: "project_id", ] - def get_related_organizations(self) -> List[Organization]: - """Retrieve the related organizations""" - if "project" in self.validated_data: - return self.validated_data["project"].get_related_organizations() - return [] - - def get_related_project(self) -> Optional[Project]: + def get_related_project(self) -> Project | None: """Retrieve the related projects""" if "project" in self.validated_data: return self.validated_data["project"] diff --git a/apps/projects/urls.py b/apps/projects/urls.py index 889fcc31..7852f827 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -1,7 +1,7 @@ from rest_framework.routers import DefaultRouter from apps.announcements.views import AnnouncementViewSet -from apps.commons.urls import project_router_register +from apps.commons.urls import organization_router_register, project_router_register from apps.feedbacks.views import ( CommentImagesView, CommentViewSet, @@ -12,6 +12,7 @@ from .views import ( BlogEntryImagesView, BlogEntryViewSet, + GeneralLocationView, GoalViewSet, HistoricalProjectViewSet, LinkedProjectViewSet, @@ -25,11 +26,13 @@ ProjectTabItemViewset, ProjectTabViewset, ProjectViewSet, - ReadLocationViewSet, ) router = DefaultRouter() -router.register(r"location", ReadLocationViewSet, basename="Read-location") + +organization_router_register( + router, r"location", GeneralLocationView, basename="General-location" +) router.register(r"project", ProjectViewSet, basename="Project") project_router_register( diff --git a/apps/projects/views.py b/apps/projects/views.py index 5e41ada8..71cf7472 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -17,7 +17,9 @@ from rest_framework.response import Response from simple_history.utils import update_change_reason +from apps.accounts.models import PeopleGroupLocation from apps.accounts.permissions import HasBasePermission +from apps.accounts.serializers import LocationPeopleGroupSerializer 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 @@ -43,7 +45,7 @@ ) from services.mistral.models import ProjectEmbedding -from .filters import LocationFilter, ProjectFilter +from .filters import ProjectFilter from .models import ( BlogEntry, Goal, @@ -612,11 +614,6 @@ def dispatch(self, request, *args, **kwargs): return super(LocationViewSet, self).dispatch(request, *args, **kwargs) -class ReadLocationViewSet(LocationViewSet): - http_method_names = ["get", "list"] - filterset_class = LocationFilter - - class HistoricalProjectViewSet(MultipleIDViewsetMixin, viewsets.ReadOnlyModelViewSet): lookup_field = "pk" permission_classes = [ReadOnly] @@ -1004,3 +1001,22 @@ 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(viewsets.GenericViewSet): + http_method_names = ["get", "list"] + + def list(self, request, *args, **kwargs): + qs_project = self.request.user.get_project_related_queryset( + Location.objects + ).select_related("project") + + qs_group = self.request.user.get_people_group_related_queryset( + PeopleGroupLocation.objects + ).select_related("people_group") + + data = { + "groups": LocationPeopleGroupSerializer(qs_group, many=True).data, + "projects": LocationSerializer(qs_project, many=True).data, + } + return Response(data, status=status.HTTP_200_OK) From e16f4c8f20047f1df36efdef07c454febd633537 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:36:43 +0100 Subject: [PATCH 07/33] missings locations --- apps/accounts/views.py | 20 ++++++++++++++++++-- apps/modules/group.py | 3 +++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 00b77832..74523788 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -40,8 +40,7 @@ from apps.files.views import ImageStorageView from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission -from apps.projects.models import Project -from apps.projects.serializers import ProjectLightSerializer +from apps.projects.serializers import LocationSerializer, ProjectLightSerializer from apps.skills.models import Skill from services.google.models import GoogleAccount, GoogleGroup from services.google.tasks import ( @@ -844,6 +843,23 @@ def similars(self, request, *args, **kwargs): ) return self.get_paginated_response(data.data) + @action( + detail=True, + methods=["GET"], + url_path="locations", + permission_classes=[ReadOnly], + ) + def locations(self, request, *args, **kwargs): + group = self.get_object() + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.locations() + + return Response( + LocationSerializer(queryset, many=True, context={"request": request}).data, + status=status.HTTP_200_OK, + ) + @extend_schema( parameters=[OpenApiParameter("people_group_id", str, OpenApiParameter.PATH)] diff --git a/apps/modules/group.py b/apps/modules/group.py index 9aa15282..f65bfef5 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -65,6 +65,9 @@ def similars(self) -> QuerySet[PeopleGroup]: def subgroups(self) -> QuerySet[PeopleGroup]: return self.instance.children.all() + def locations(self) -> QuerySet[Location]: + return Location.objects.filter(project__in=self.featured_projects()) + def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: members_qs = self.members() return Document.objects.filter( From 45784be65f1580799158fb18177cd4a63c4379a7 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:39:14 +0100 Subject: [PATCH 08/33] missing serialier --- apps/accounts/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 1cb454de..4c5edc34 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -604,6 +604,7 @@ class Meta: "roles", "sdgs", "tags", + "location", "publication_status", "team", "featured_projects", From a745f08a930badf6ea4a3e74f786f1fc974c7712 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:50:05 +0100 Subject: [PATCH 09/33] fix missings tags --- apps/accounts/admin.py | 1 - apps/accounts/serializers.py | 3 +-- apps/accounts/views.py | 1 - apps/commons/mixins.py | 2 +- apps/modules/group.py | 2 +- apps/projects/serializers.py | 5 ++++- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index a463f0ec..3fa4d5cc 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -169,7 +169,6 @@ class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): list_filter = ("organization",) - @admin.register(Permission) class PermissionAdmin(admin.ModelAdmin): list_display = ("name", "codename", "content_type") diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 42195db4..290f4722 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -234,7 +234,7 @@ class PeopleGroupSuperLightSerializer( class Meta: model = PeopleGroup - read_only_fields = ["id", "slug", "name", "organization"] + read_only_fields = ["id", "slug", "name", "short_description", "organization"] fields = read_only_fields @@ -555,7 +555,6 @@ class Meta: "parent", "organization", "hierarchy", - "children", "header_image", "logo_image", "roles", diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 00b77832..aba1d1d8 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -40,7 +40,6 @@ from apps.files.views import ImageStorageView from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission -from apps.projects.models import Project from apps.projects.serializers import ProjectLightSerializer from apps.skills.models import Skill from services.google.models import GoogleAccount, GoogleGroup diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 8349207d..2ece75f0 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Self, Tuple from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType diff --git a/apps/modules/group.py b/apps/modules/group.py index 9aa15282..829da8ab 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -2,7 +2,7 @@ from apps.accounts.models import PeopleGroup, ProjectUser from apps.modules.base import AbstractModules, register_module -from apps.projects.models import Location, Project +from apps.projects.models import Project from apps.skills.models import Skill from services.crisalid.models import Document, DocumentTypeCentralized diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 47ca260c..68f01402 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -38,7 +38,7 @@ ProjectTemplateSerializer, ) from apps.skills.models import Tag -from apps.skills.serializers import TagRelatedField +from apps.skills.serializers import TagRelatedField, TagSerializer from services.translator.serializers import AutoTranslatedModelSerializer from .exceptions import ( @@ -238,6 +238,7 @@ class ProjectLightSerializer( is_followed = serializers.SerializerMethodField(read_only=True) 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: model = Project @@ -256,6 +257,8 @@ class Meta: "is_followed", "is_featured", "is_group_project", + "updated_at", + "tags", ] def get_is_followed(self, project: Project) -> Dict[str, Any]: From 31013aeffed9861555bbceaaca4362fb8c050dcd Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:52:47 +0100 Subject: [PATCH 10/33] linter --- apps/commons/serializers.py | 1 + apps/files/models.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/commons/serializers.py b/apps/commons/serializers.py index 1d69b3f2..e040ab44 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -11,6 +11,7 @@ from apps.files.models import Image from apps.organizations.models import Organization from apps.projects.models import Project +from services.translator.serializers import AutoTranslatedModelSerializer class ProjectRelatedSerializer(serializers.ModelSerializer): diff --git a/apps/files/models.py b/apps/files/models.py index 50f80912..dac34e25 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -1,7 +1,7 @@ import datetime import uuid from contextlib import suppress -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Self from azure.core.exceptions import ResourceNotFoundError from django.apps import apps @@ -394,7 +394,7 @@ def duplicate(self, upload_to: str = "", **fields) -> None | Self: content=self.file.read(), content_type=f"image/{file_extension}", ) - _upload_to = lambda instance, filename: upload_to + _upload_to = lambda instance, filename: upload_to # noqa: E731 return super().duplicate(_upload_to=_upload_to, file=new_file, **fields) return None From 16a4f3829adddb848ec041af593434cc1c55a2a5 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:55:41 +0100 Subject: [PATCH 11/33] doc --- services/crisalid/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/crisalid/views.py b/services/crisalid/views.py index 95debbed..e0c66d2c 100644 --- a/services/crisalid/views.py +++ b/services/crisalid/views.py @@ -15,7 +15,7 @@ from rest_framework import viewsets from rest_framework.decorators import action -from apps.commons.views import NestedOrganizationViewMixins +from apps.commons.views import NestedOrganizationViewMixins, NestedPeopleGroupViewMixins from services.crisalid import relators from services.crisalid.models import ( Document, @@ -199,6 +199,14 @@ def analytics(self, request, *args, **kwargs): ).data ) +class AbstractGroupDocumentViewSet( + NestedPeopleGroupViewMixins, AbstractDocumentViewSet +): + def get_queryset(self): + modules_manager = self.people_group.get_related_module() + modules = modules_manager(self.people_group, self.request.user) + return getattr(modules, self.document_name)() + class PublicationViewSet(AbstractDocumentViewSet): document_types = DocumentTypeCentralized.publications From 8111e91dd5b4e3e5dedc7391b1683ae0fcbfd2c0 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 14:58:03 +0100 Subject: [PATCH 12/33] documents view --- services/crisalid/urls.py | 26 ++++++++ services/crisalid/views.py | 119 ++++++++++++++++++++++++++++--------- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/services/crisalid/urls.py b/services/crisalid/urls.py index 8a2b612e..5c869060 100644 --- a/services/crisalid/urls.py +++ b/services/crisalid/urls.py @@ -2,11 +2,15 @@ from rest_framework.routers import DefaultRouter from apps.commons.urls import ( + organization_people_group_router_register, organization_researcher_router_register, organization_router_register, ) from services.crisalid.views import ( ConferenceViewSet, + DocumentViewSet, + GroupConferenceViewSet, + GroupPublicationViewSet, PublicationViewSet, ResearcherViewSet, ) @@ -17,6 +21,13 @@ researcher_router, r"researcher", ResearcherViewSet, basename="Researcher" ) +organization_router_register( + researcher_router, + r"document", + DocumentViewSet, + basename="CrisalidDocument", +) + organization_researcher_router_register( researcher_router, r"publications", @@ -31,6 +42,21 @@ basename="ResearcherConferences", ) +# -- group +organization_people_group_router_register( + researcher_router, + r"publications", + GroupPublicationViewSet, + basename="GroupResearcherPublications", +) + +organization_people_group_router_register( + researcher_router, + r"conferences", + GroupConferenceViewSet, + basename="GroupResearcherConferences", +) + urlpatterns = [ path("", include(researcher_router.urls)), ] diff --git a/services/crisalid/views.py b/services/crisalid/views.py index e0c66d2c..63a60c5c 100644 --- a/services/crisalid/views.py +++ b/services/crisalid/views.py @@ -82,14 +82,25 @@ ), ) class AbstractDocumentViewSet( - NestedOrganizationViewMixins, - NestedResearcherViewMixins, viewsets.ReadOnlyModelViewSet, ): """Abstract class to get documents info from documents types""" serializer_class = DocumentSerializer + def filter_roles(self, queryset, roles_enabled=True): + # filter only by roles (author, co-authors ...ect) + roles = [ + r.strip() + for r in self.request.query_params.get("roles", "").split(",") + if r.strip() + ] + if roles and roles_enabled: + queryset = queryset.filter( + documentcontributor__roles__contains=roles, + ) + return queryset + def filter_queryset( self, queryset, @@ -102,17 +113,7 @@ def filter_queryset( if year and year_enabled: qs = qs.filter(publication_date__year=year) - # filter only by roles (author, co-authors ...ect) - roles = [ - r.strip() - for r in self.request.query_params.get("roles", "").split(",") - if r.strip() - ] - if roles and roles_enabled: - qs = qs.filter( - documentcontributor__roles__contains=roles, - documentcontributor__researcher=self.researcher, - ) + qs = self.filter_roles(qs, roles_enabled) # filter by pblication_type if "document_type" in self.request.query_params and document_type_enabled: @@ -123,7 +124,6 @@ def filter_queryset( def get_queryset(self) -> QuerySet[Document]: return ( Document.objects.filter( - contributors=self.researcher, document_type__in=self.document_types, ) .prefetch_related("identifiers", "contributors__user") @@ -146,22 +146,17 @@ def similars(self, request, *args, **kwargs): ) return self.get_paginated_response(data.data) - @action( - detail=False, - methods=[HTTPMethod.GET], - url_path="analytics", - serializer_class=DocumentAnalyticsSerializer, - ) - def analytics(self, request, *args, **kwargs): - """methods to return analytics (how many documents/by year / by document_type) from researcher""" - + def get_analytics(self): qs = self.get_queryset() # get counted all document_types types # use only here the filter_queryset, # the next years values need to have all document_types (non filtered) + document_types = Counter( - self.filter_queryset(qs, document_type_enabled=False) + Document.objects.filter( + id__in=self.filter_queryset(qs, document_type_enabled=False) + ) .order_by("document_type") .values_list("document_type", flat=True) ) @@ -184,11 +179,23 @@ def analytics(self, request, *args, **kwargs): chain( *DocumentContributor.objects.filter( document__in=self.filter_queryset(qs, roles_enabled=False), - researcher=self.researcher, ).values_list("roles", flat=True) ) ) + return document_types, years, roles + + @action( + detail=False, + methods=[HTTPMethod.GET], + url_path="analytics", + serializer_class=DocumentAnalyticsSerializer, + ) + def analytics(self, request, *args, **kwargs): + """methods to return analytics (how many documents/by year / by document_type) from researcher""" + + document_types, years, roles = self.get_analytics() + return JsonResponse( self.serializer_class( { @@ -199,6 +206,18 @@ def analytics(self, request, *args, **kwargs): ).data ) + +class DocumentViewSet(NestedOrganizationViewMixins, AbstractDocumentViewSet): + """general viewset documents""" + + def get_queryset(self) -> QuerySet[Document]: + return ( + Document.objects.all() + .prefetch_related("identifiers", "contributors__user") + .order_by("-publication_date") + ) + + class AbstractGroupDocumentViewSet( NestedPeopleGroupViewMixins, AbstractDocumentViewSet ): @@ -208,11 +227,57 @@ def get_queryset(self): return getattr(modules, self.document_name)() -class PublicationViewSet(AbstractDocumentViewSet): +class AbstractResearcherDocumentViewSet( + NestedOrganizationViewMixins, NestedResearcherViewMixins, AbstractDocumentViewSet +): + + def filter_roles(self, queryset, roles_enabled=True): + # filter only by roles (author, co-authors ...ect) + roles = [ + r.strip() + for r in self.request.query_params.get("roles", "").split(",") + if r.strip() + ] + if roles and roles_enabled: + queryset = queryset.filter( + documentcontributor__roles__contains=roles, + documentcontributor__research=self.researcher, + ) + return queryset + + def get_analytics(self): + document_types, years, _ = super().get_analytics() + qs = self.get_queryset() + roles = Counter( + chain( + *DocumentContributor.objects.filter( + document__in=self.filter_queryset(qs, roles_enabled=False), + researcher=self.researcher, + ).values_list("roles", flat=True) + ) + ) + + return (document_types, years, roles) + + def get_queryset(self) -> QuerySet[Document]: + return super().get_queryset().filter(contributors=self.researcher) + + +class GroupPublicationViewSet(AbstractGroupDocumentViewSet): + document_name = "publications" + document_types = DocumentTypeCentralized.publications + + +class GroupConferenceViewSet(AbstractGroupDocumentViewSet): + document_name = "conferences" + document_types = DocumentTypeCentralized.conferences + + +class PublicationViewSet(AbstractResearcherDocumentViewSet): document_types = DocumentTypeCentralized.publications -class ConferenceViewSet(AbstractDocumentViewSet): +class ConferenceViewSet(AbstractResearcherDocumentViewSet): document_types = DocumentTypeCentralized.conferences From 4b9f8428b91164abff19d5a54413f7cc67966115 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 15:39:35 +0100 Subject: [PATCH 13/33] migrations --- apps/accounts/admin.py | 2 +- .../migrations/0003_peoplegroup_tags.py | 19 ++++++++ .../mistral/migrations/0005_groupembedding.py | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 apps/accounts/migrations/0003_peoplegroup_tags.py create mode 100644 services/mistral/migrations/0005_groupembedding.py diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index 3fa4d5cc..d6d913e2 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -89,7 +89,7 @@ class Meta: verbose_name = "User" -admin.unregister(Group) +admin.site.unregister(Group) @admin.register(Group) diff --git a/apps/accounts/migrations/0003_peoplegroup_tags.py b/apps/accounts/migrations/0003_peoplegroup_tags.py new file mode 100644 index 00000000..9d1cc86f --- /dev/null +++ b/apps/accounts/migrations/0003_peoplegroup_tags.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.10 on 2026-02-10 14:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0002_initial"), + ("skills", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="peoplegroup", + name="tags", + field=models.ManyToManyField(related_name="people_groups", to="skills.tag"), + ), + ] diff --git a/services/mistral/migrations/0005_groupembedding.py b/services/mistral/migrations/0005_groupembedding.py new file mode 100644 index 00000000..503445d9 --- /dev/null +++ b/services/mistral/migrations/0005_groupembedding.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.10 on 2026-02-10 14:38 + +import django.db.models.deletion +import pgvector.django +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0003_peoplegroup_tags"), + ("mistral", "0004_documentembedding"), + ] + + operations = [ + migrations.CreateModel( + name="GroupEmbedding", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("last_update", models.DateTimeField(auto_now=True)), + ("embedding", pgvector.django.VectorField(dimensions=1024, null=True)), + ("is_visible", models.BooleanField(default=False)), + ("summary", models.TextField(blank=True)), + ("prompt_hashcode", models.CharField(default="", max_length=64)), + ( + "item", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="embedding", + to="accounts.peoplegroup", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] From 0389469c7b9f968ce367e16e150886cc0f0ac342 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 16:04:16 +0100 Subject: [PATCH 14/33] drf openapi --- apps/accounts/views.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/accounts/views.py b/apps/accounts/views.py index aba1d1d8..ca46ca1e 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -25,7 +25,10 @@ from rest_framework.decorators import action from rest_framework.filters import OrderingFilter from rest_framework.parsers import JSONParser -from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly +from rest_framework.permissions import ( + IsAuthenticated, + IsAuthenticatedOrReadOnly, +) from rest_framework.response import Response from rest_framework.serializers import BooleanField from rest_framework.views import APIView @@ -33,7 +36,10 @@ from apps.commons.filters import UnaccentSearchFilter from apps.commons.models import GroupData from apps.commons.permissions import IsOwner, ReadOnly, WillBeOwner -from apps.commons.serializers import EmailAddressSerializer, RetrieveUpdateModelViewSet +from apps.commons.serializers import ( + EmailAddressSerializer, + RetrieveUpdateModelViewSet, +) from apps.commons.utils import map_action_to_permission from apps.commons.views import DetailOnlyViewsetMixin, MultipleIDViewsetMixin from apps.files.models import Image @@ -753,6 +759,7 @@ def remove_featured_project(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema( + response=ProjectLightSerializer(many=True), parameters=[ OpenApiParameter( name="limit", @@ -766,7 +773,7 @@ def remove_featured_project(self, request, *args, **kwargs): required=False, type=int, ), - ] + ], ) @action( detail=True, @@ -781,17 +788,12 @@ def project(self, request, *args, **kwargs): queryset = modules.featured_projects() page = self.paginate_queryset(queryset) - if page is not None: - project_serializer = ProjectLightSerializer( - page, context={"request": request}, many=True - ) - return self.get_paginated_response(project_serializer.data) - project_serializer = ProjectLightSerializer( - queryset, context={"request": request}, many=True + page, context={"request": request}, many=True ) - return Response(project_serializer.data) + return self.get_paginated_response(project_serializer.data) + @extend_schema(responses=PeopleGroupHierarchySerializer) @action( detail=True, methods=["GET"], @@ -807,6 +809,7 @@ def hierarchy(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) + @extend_schema(responses=PeopleGroupLightSerializer(many=True)) @action( detail=True, methods=["GET"], @@ -820,11 +823,12 @@ def subgroups(self, request, *args, **kwargs): queryset = modules.subgroups() queryset_page = self.paginate_queryset(queryset) - data = self.serializer_class( + data = PeopleGroupLightSerializer( queryset_page, many=True, context={"request": request} ) return self.get_paginated_response(data.data) + @extend_schema(responses=PeopleGroupLightSerializer(many=True)) @action( detail=True, methods=["GET"], From 8c8560a014e41fd896ea3dac2a336e82f0922c59 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 16:55:27 +0100 Subject: [PATCH 15/33] fix errors --- apps/accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/accounts/views.py b/apps/accounts/views.py index ca46ca1e..23f40420 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -759,7 +759,7 @@ def remove_featured_project(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema( - response=ProjectLightSerializer(many=True), + responses=ProjectLightSerializer(many=True), parameters=[ OpenApiParameter( name="limit", From 2c946265f656fac17c689b9160ebdbd6994b7e87 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 17:18:05 +0100 Subject: [PATCH 16/33] refactor: duplicate modules --- apps/announcements/models.py | 11 ------- apps/commons/mixins.py | 36 ++++++++++++++++------- apps/files/models.py | 47 ++++------------------------- apps/projects/models.py | 57 ++++++++---------------------------- 4 files changed, 43 insertions(+), 108 deletions(-) diff --git a/apps/announcements/models.py b/apps/announcements/models.py index f271e5de..ba4cf784 100644 --- a/apps/announcements/models.py +++ b/apps/announcements/models.py @@ -78,16 +78,5 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "Announcement": - return Announcement.objects.create( - project=project, - description=self.description, - title=self.title, - type=self.type, - status=self.status, - deadline=self.deadline, - is_remunerated=self.is_remunerated, - ) - def __str__(self): return str(self.title) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 84ef04d5..c05f59da 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple +from collections.abc import Iterable +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Optional, Self from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType @@ -35,7 +37,7 @@ def organization_query(cls, key: str, value: Any) -> Q: return Q(**{cls.organization_query_string: value}) return Q(**{key: value}) - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" raise NotImplementedError() @@ -91,7 +93,7 @@ def get_related_project(self) -> Optional["Project"]: """Return the projects related to this model.""" raise NotImplementedError() - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" raise NotImplementedError() @@ -184,7 +186,7 @@ def setup_permissions(self, user: Optional["ProjectUser"] = None): @classmethod def batch_reassign_permissions( - cls, roles_permissions: Tuple[str, Iterable[Permission]] + cls, roles_permissions: tuple[str, Iterable[Permission]] ): """ Reassign permissions for all instances of the model. @@ -268,8 +270,20 @@ class DuplicableModel: A model that can be duplicated. """ - def duplicate(self, *args, **kwargs) -> "DuplicableModel": - raise NotImplementedError() + def duplicate(self, **fields) -> type[Self]: + """duplicate models elements, set new fields + + :return: new models + """ + + instance_copy = deepcopy(self) + instance_copy.pk = None + + for name, value in fields.items(): + setattr(instance_copy, name, value) + + instance_copy.save() + return instance_copy class HasMultipleIDs: @@ -320,9 +334,9 @@ def get_id_field_name(cls, object_id: Any) -> str: The outdated slugs of the object. They are kept for url retro-compatibility. """ - _original_slug_fields_value: Dict[str, str] = {} - slugified_fields: List[str] = [] - reserved_slugs: List[str] = [] + _original_slug_fields_value: dict[str, str] = {} + slugified_fields: list[str] = [] + reserved_slugs: list[str] = [] slug_prefix: str = "" def __init__(self, *args, **kwargs): @@ -371,8 +385,8 @@ def get_main_id(cls, object_id: Any, returned_field: str = "id") -> Any: @classmethod def get_main_ids( - cls, objects_ids: List[Any], returned_field: str = "id" - ) -> List[Any]: + cls, objects_ids: list[Any], returned_field: str = "id" + ) -> list[Any]: """Get the main IDs from a list of secondary IDs.""" return [cls.get_main_id(object_id, returned_field) for object_id in objects_ids] diff --git a/apps/files/models.py b/apps/files/models.py index b67010f8..e5c0c4de 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -1,7 +1,7 @@ import datetime import uuid from contextlib import suppress -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Self from azure.core.exceptions import ResourceNotFoundError from django.apps import apps @@ -10,6 +10,7 @@ from django.db import models, transaction from django.db.models import ForeignObjectRel, Model, Q, QuerySet from django.utils import timezone +from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords from stdimage import StdImageField @@ -19,7 +20,6 @@ OrganizationRelated, ProjectRelated, ) -from services.translator.mixins import HasAutoTranslatedFields from .enums import AttachmentLinkCategory, AttachmentType from .utils import resize_and_autorotate @@ -127,18 +127,6 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "AttachmentLink": - return AttachmentLink.objects.create( - project=project, - attachment_type=self.attachment_type, - category=self.category, - description=self.description, - preview_image_url=self.preview_image_url, - site_name=self.site_name, - site_url=self.site_url, - title=self.title, - ) - class OrganizationAttachmentFile( HasAutoTranslatedFields, OrganizationRelated, models.Model @@ -226,15 +214,7 @@ def duplicate(self, project: "Project") -> Optional["AttachmentFile"]: content=self.file.read(), content_type=f"application/{file_extension}", ) - return AttachmentFile.objects.create( - project=project, - attachment_type=self.attachment_type, - file=new_file, - mime=self.mime, - title=self.title, - description=self.description, - hashcode=self.hashcode, - ) + return super().duplicate(project=project, file=new_file) return None @@ -400,9 +380,7 @@ def get_related_project(self) -> Optional["Project"]: return queryset.first() return None - def duplicate( - self, owner: Optional["ProjectUser"] = None, upload_to: str = "" - ) -> Optional["Image"]: + def duplicate(self, upload_to: str = "", **fields) -> None | type[Self]: with suppress(ResourceNotFoundError): file_path = self.file.name.split("/") file_name = file_path.pop() @@ -416,21 +394,8 @@ def duplicate( content=self.file.read(), content_type=f"image/{file_extension}", ) - image = Image( - name=self.name, - file=new_file, - height=self.height, - width=self.width, - natural_ratio=self.natural_ratio, - scale_x=self.scale_x, - scale_y=self.scale_y, - left=self.left, - top=self.top, - owner=owner or self.owner, - ) - image._upload_to = lambda instance, filename: upload_to - image.save() - return image + _upload_to = lambda instance, filename: upload_to # noqa: E731 + return super().duplicate(_upload_to=_upload_to, file=new_file, **fields) return None diff --git a/apps/projects/models.py b/apps/projects/models.py index 46053853..8cf7a379 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -553,19 +553,11 @@ def calculate_score(self) -> "ProjectScore": @transaction.atomic def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": - header = self.header_image.duplicate(owner) if self.header_image else None - project = Project.objects.create( - title=self.title, + header = self.header_image.duplicate(owner=owner) if self.header_image else None + project = super().duplicate( header_image=header, - description=self.description, - purpose=self.purpose, - is_locked=self.is_locked, - is_shareable=self.is_shareable, publication_status=Project.PublicationStatus.PRIVATE, - life_status=self.life_status, - language=self.language, - sdgs=self.sdgs, - template=self.template, + # TODO(remi): add this id (or fk) directly in DuplicateMixins duplicated_from=self.id, ) project.setup_permissions(user=owner) @@ -573,7 +565,7 @@ def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": project.organizations.set(self.organizations.all()) project.tags.set(self.tags.all()) for image in self.images.all(): - new_image = image.duplicate(owner) + new_image = image.duplicate(owner=owner) if new_image is not None: project.images.add(new_image) for identifier in [self.pk, self.slug]: @@ -583,17 +575,17 @@ def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": ) project.save() for blog_entry in self.blog_entries.all(): - blog_entry.duplicate(project, self, owner) + blog_entry.duplicate(project=project, initial_project=self, owner=owner) for announcement in self.announcements.all(): - announcement.duplicate(project) + announcement.duplicate(project=project) for location in self.locations.all(): - location.duplicate(project) + location.duplicate(project=project) for goal in self.goals.all(): - goal.duplicate(project) + goal.duplicate(project=project) for link in self.links.all(): - link.duplicate(project) + link.duplicate(project=project) for file in self.files.all(): - file.duplicate(project) + file.duplicate(project=project) Stat.objects.create(project=project) return project @@ -768,13 +760,9 @@ def duplicate( initial_project: Optional["Project"] = None, owner: Optional["ProjectUser"] = None, ) -> "BlogEntry": - blog_entry = BlogEntry.objects.create( - project=project, - title=self.title, - content=self.content, - ) + blog_entry = super().duplicate(project=project) for image in self.images.all(): - new_image = image.duplicate(owner) + new_image = image.duplicate(owner=owner) if new_image is not None: blog_entry.images.add(new_image) for identifier in [initial_project.pk, initial_project.slug]: @@ -782,8 +770,6 @@ def duplicate( f"/v1/project/{identifier}/blog-entry-image/{image.pk}/", f"/v1/project/{project.pk}/blog-entry-image/{new_image.pk}/", ) - blog_entry.created_at = self.created_at - blog_entry.save() return blog_entry @@ -851,15 +837,6 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self.project - def duplicate(self, project: "Project") -> "Goal": - return Goal.objects.create( - project=project, - title=self.title, - description=self.description, - deadline_at=self.deadline_at, - status=self.status, - ) - class Location( HasAutoTranslatedFields, @@ -916,16 +893,6 @@ def get_related_organizations(self) -> List["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() - def duplicate(self, project: "Project") -> "Location": - return Location.objects.create( - project=project, - title=self.title, - description=self.description, - lat=self.lat, - lng=self.lng, - type=self.type, - ) - class ProjectMessage( HasAutoTranslatedFields, From 568f51c3418f4a2c1bdf82fecc7d1dfaa6a1f628 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 17:24:42 +0100 Subject: [PATCH 17/33] locales --- locale/ca/LC_MESSAGES/django.po | 4 ++-- locale/de/LC_MESSAGES/django.po | 4 ++-- locale/en/LC_MESSAGES/django.po | 4 ++-- locale/es/LC_MESSAGES/django.po | 4 ++-- locale/et/LC_MESSAGES/django.po | 4 ++-- locale/fr/LC_MESSAGES/django.po | 4 ++-- locale/nl/LC_MESSAGES/django.po | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 2daf0109..cee8a286 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "visibilitat" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index da18dcdb..87df3298 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "Sichtbarkeit" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index dc59aeda..baf5df2c 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index ddb05f29..0f2adf6d 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "visibilidad" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 29bd2ce7..cd72143e 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "nähtavus" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index cd3ed07a..d0d15445 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "visibilité" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 886b70c0..335048c7 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: 2025-12-17 16:58+0100\n" +"POT-Creation-Date: 2026-02-10 17:21+0100\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:140 apps/projects/models.py:161 +#: apps/accounts/models.py:144 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" From 992622b15fdc7ddff51a489eb0bda86ce1bb083c Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 10 Feb 2026 17:25:59 +0100 Subject: [PATCH 18/33] linter --- apps/files/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/files/models.py b/apps/files/models.py index e5c0c4de..adbf4963 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -10,7 +10,6 @@ from django.db import models, transaction from django.db.models import ForeignObjectRel, Model, Q, QuerySet from django.utils import timezone -from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords from stdimage import StdImageField @@ -20,6 +19,7 @@ OrganizationRelated, ProjectRelated, ) +from services.translator.mixins import HasAutoTranslatedFields from .enums import AttachmentLinkCategory, AttachmentType from .utils import resize_and_autorotate From 0356d11e60d1eb6f8af41c97c308e6dc0d7d26eb Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 11 Feb 2026 15:29:20 +0100 Subject: [PATCH 19/33] fix duplicate --- apps/commons/mixins.py | 9 +++++++-- apps/projects/models.py | 16 +++++++++++++--- apps/projects/tests/views/test_project.py | 6 ++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index c05f59da..93f94635 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,5 +1,6 @@ from collections.abc import Iterable -from copy import deepcopy +from contextlib import suppress +from copy import copy from typing import TYPE_CHECKING, Any, Optional, Self from django.contrib.auth.models import Group, Permission @@ -276,12 +277,16 @@ def duplicate(self, **fields) -> type[Self]: :return: new models """ - instance_copy = deepcopy(self) + instance_copy = copy(self) instance_copy.pk = None for name, value in fields.items(): setattr(instance_copy, name, value) + # remove prefetch m2m + with suppress(AttributeError): + del instance_copy._prefetched_objects_cache + instance_copy.save() return instance_copy diff --git a/apps/projects/models.py b/apps/projects/models.py index 8cf7a379..8a0e20de 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -555,25 +555,33 @@ def calculate_score(self) -> "ProjectScore": def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": header = self.header_image.duplicate(owner=owner) if self.header_image else None project = super().duplicate( + slug=None, + outdated_slugs=[], header_image=header, publication_status=Project.PublicationStatus.PRIVATE, # TODO(remi): add this id (or fk) directly in DuplicateMixins duplicated_from=self.id, ) - project.setup_permissions(user=owner) + project.categories.set(self.categories.all()) project.organizations.set(self.organizations.all()) project.tags.set(self.tags.all()) + project.setup_permissions(user=owner) + + images_to_set = [] for image in self.images.all(): new_image = image.duplicate(owner=owner) if new_image is not None: - project.images.add(new_image) + images_to_set.append(new_image) for identifier in [self.pk, self.slug]: project.description = project.description.replace( f"/v1/project/{identifier}/image/{image.pk}/", f"/v1/project/{project.pk}/image/{new_image.pk}/", ) - project.save() + project.images.set(images_to_set) + if images_to_set: + project.save() + for blog_entry in self.blog_entries.all(): blog_entry.duplicate(project=project, initial_project=self, owner=owner) for announcement in self.announcements.all(): @@ -587,6 +595,8 @@ def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": for file in self.files.all(): file.duplicate(project=project) Stat.objects.create(project=project) + + project.refresh_from_db() return project diff --git a/apps/projects/tests/views/test_project.py b/apps/projects/tests/views/test_project.py index b9b0eb36..a458ae9e 100644 --- a/apps/projects/tests/views/test_project.py +++ b/apps/projects/tests/views/test_project.py @@ -497,6 +497,12 @@ def check_duplicated_project(self, duplicated_project: Dict, initial_project: Di self.assertEqual( 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"] + ) for field in fields: self.assertEqual(duplicated_project[field], initial_project[field]) From 0731f7290f0fc0b7828715aa7d876766663109bf Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 11 Feb 2026 15:46:43 +0100 Subject: [PATCH 20/33] fix filter --- apps/accounts/tests/views/test_people_group.py | 18 +++++++++++++++--- apps/modules/group.py | 8 ++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 79fcdfbd..8e8d5aae 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -6,7 +6,11 @@ from parameterized import parameterized from rest_framework import status -from apps.accounts.factories import PeopleGroupFactory, SeedUserFactory, UserFactory +from apps.accounts.factories import ( + PeopleGroupFactory, + SeedUserFactory, + UserFactory, +) from apps.accounts.models import PeopleGroup from apps.accounts.utils import get_superadmins_group from apps.commons.models import GroupData @@ -235,8 +239,16 @@ def test_retrieve_people_group_hierarchy( self.assertEqual(hierarchy[i]["id"], self.parents[parent].id) self.assertEqual(hierarchy[i]["order"], i) - children = content["children"] - self.assertEqual(len(children), len(expected_children)) + subgroups_count = content["modules"]["subgroups"] + self.assertEqual(subgroups_count, len(expected_children)) + + response = self.client.get( + reverse( + "PeopleGroup-subgroups", + args=(self.organization.code, self.group.id), + ), + ) + children = response.json()["results"] self.assertSetEqual( {child["id"] for child in children}, {self.children[child].id for child in expected_children}, diff --git a/apps/modules/group.py b/apps/modules/group.py index 829da8ab..18109705 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -60,10 +60,14 @@ def featured_projects(self) -> QuerySet[Project]: ) def similars(self) -> QuerySet[PeopleGroup]: - return self.instance.similars() + return self.instance.similars().filter( + pk__in=self.user.get_people_group_queryset() + ) def subgroups(self) -> QuerySet[PeopleGroup]: - return self.instance.children.all() + return self.instance.children.filter( + pk__in=self.user.get_people_group_queryset() + ) def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: members_qs = self.members() From 0861ab0344bd8f1f34d0fc98fbcb2cc01703b6c5 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 11 Feb 2026 16:32:24 +0100 Subject: [PATCH 21/33] fix m2m --- apps/projects/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/projects/models.py b/apps/projects/models.py index 8a0e20de..b4cf08c9 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -579,8 +579,6 @@ def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": f"/v1/project/{project.pk}/image/{new_image.pk}/", ) project.images.set(images_to_set) - if images_to_set: - project.save() for blog_entry in self.blog_entries.all(): blog_entry.duplicate(project=project, initial_project=self, owner=owner) @@ -594,9 +592,10 @@ def duplicate(self, owner: Optional["ProjectUser"] = None) -> "Project": link.duplicate(project=project) for file in self.files.all(): file.duplicate(project=project) + Stat.objects.create(project=project) - project.refresh_from_db() + project.save() return project @@ -771,15 +770,18 @@ def duplicate( owner: Optional["ProjectUser"] = None, ) -> "BlogEntry": blog_entry = super().duplicate(project=project) + images_to_set = [] for image in self.images.all(): new_image = image.duplicate(owner=owner) if new_image is not None: - blog_entry.images.add(new_image) + images_to_set.append(new_image) for identifier in [initial_project.pk, initial_project.slug]: blog_entry.content = blog_entry.content.replace( f"/v1/project/{identifier}/blog-entry-image/{image.pk}/", f"/v1/project/{project.pk}/blog-entry-image/{new_image.pk}/", ) + blog_entry.images.set(images_to_set) + blog_entry.save() return blog_entry From b8307066b0354d39cf6cebea58469f65f5447fa8 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 11 Feb 2026 17:43:10 +0100 Subject: [PATCH 22/33] fix embeding --- apps/commons/mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/commons/mixins.py b/apps/commons/mixins.py index 1a297237..2efb9a5d 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -441,7 +441,7 @@ def get_related_module(self): class HasEmbending: def vectorize(self): if not getattr(self, "embedding", None): - model_embedding = type(self.embedding) + model_embedding = type(self).embedding.related.related_model self.embedding = model_embedding(item=self) self.embedding.save() self.embedding.vectorize() @@ -450,7 +450,7 @@ def similars(self, threshold: float = 0.15) -> QuerySet[Self]: """return similars documents""" if getattr(self, "embedding", None): vector = self.embedding.embedding - model_embedding = type(self.embedding) + model_embedding = type(self).embedding.related.related_model queryset = type(self).objects.all() return model_embedding.vector_search(vector, queryset, threshold).exclude( pk=self.pk From 3aa4bcb67b1f2b0ef20b3cae0cc4db310d55176c Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 12:07:17 +0100 Subject: [PATCH 23/33] fix location tests --- ...eoplegrouplocation_peoplegroup_location.py | 87 +++++++++++++++++++ apps/accounts/serializers.py | 43 ++++----- .../accounts/tests/views/test_people_group.py | 80 ++++++++++++++++- apps/commons/serializers.py | 15 ++++ apps/modules/group.py | 2 +- .../migrations/0002_alter_location_type.py | 26 ++++++ 6 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py create mode 100644 apps/projects/migrations/0002_alter_location_type.py diff --git a/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py b/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py new file mode 100644 index 00000000..53529395 --- /dev/null +++ b/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2.11 on 2026-02-12 09:28 + +import apps.commons.mixins +import django.db.models.deletion +import services.translator.mixins +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0003_peoplegroup_tags"), + ] + + operations = [ + migrations.CreateModel( + name="PeopleGroupLocation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(blank=True, max_length=255)), + ("description", models.TextField(blank=True)), + ("lat", models.FloatField()), + ("lng", models.FloatField()), + ( + "type", + models.CharField( + choices=[ + ("team", "Team"), + ("impact", "Impact"), + ("address", "Address"), + ], + default="team", + max_length=10, + ), + ), + ( + "title_detected_language", + models.CharField(blank=True, max_length=10, null=True), + ), + ("title_en", models.CharField(blank=True, max_length=1020, null=True)), + ("title_fr", models.CharField(blank=True, max_length=1020, null=True)), + ("title_de", models.CharField(blank=True, max_length=1020, null=True)), + ("title_nl", models.CharField(blank=True, max_length=1020, null=True)), + ("title_et", models.CharField(blank=True, max_length=1020, null=True)), + ("title_ca", models.CharField(blank=True, max_length=1020, null=True)), + ("title_es", models.CharField(blank=True, max_length=1020, null=True)), + ( + "description_detected_language", + models.CharField(blank=True, max_length=10, null=True), + ), + ("description_en", models.TextField(blank=True, null=True)), + ("description_fr", models.TextField(blank=True, null=True)), + ("description_de", models.TextField(blank=True, null=True)), + ("description_nl", models.TextField(blank=True, null=True)), + ("description_et", models.TextField(blank=True, null=True)), + ("description_ca", models.TextField(blank=True, null=True)), + ("description_es", models.TextField(blank=True, null=True)), + ], + options={ + "abstract": False, + }, + bases=( + services.translator.mixins.HasAutoTranslatedFields, + apps.commons.mixins.DuplicableModel, + models.Model, + ), + ), + migrations.AddField( + model_name="peoplegroup", + name="location", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="people_group", + to="accounts.peoplegrouplocation", + ), + ), + ] diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 207285da..93dd8f6e 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -241,19 +241,6 @@ class Meta(BaseLocationSerializer.Meta): model = PeopleGroupLocation -class PeopleGroupLocationRelated(serializers.RelatedField): - def get_queryset(self): - return PeopleGroupLocation.objects.all() - - def to_representation(self, instance: PeopleGroupLocation) -> dict: - return PeopleGroupLocationSerializer(instance=instance).data - - def to_internal_value(self, element: dict) -> PeopleGroupLocation: - if element.get("pk"): - return PeopleGroupLocation.objects.get(pk=element["pk"]) - return PeopleGroupLocation(**element) - - class PeopleGroupSuperLightSerializer( AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -492,7 +479,7 @@ class PeopleGroupSerializer( child=serializers.IntegerField(min_value=1, max_value=17), required=False, ) - location = PeopleGroupLocationRelated(required=False, allow_null=True) + location = PeopleGroupLocationSerializer(required=False, allow_null=True) def get_hierarchy(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: request = self.context.get("request") @@ -551,14 +538,20 @@ def validate_parent(self, value): parent = parent.parent return value + def validate_location(self, values): + location_serializer = PeopleGroupLocationSerializer( + data=values, allow_null=True + ) + location_serializer.is_valid(raise_exception=True) + return location_serializer.validated_data + def create(self, validated_data): team = validated_data.pop("team", {}) featured_projects = validated_data.pop("featured_projects", []) - location = validated_data.pop("location", {}) + location = validated_data.pop("location", None) - if location: - location.save() - validated_data["id"] = location + if location is not None: + validated_data["location"] = PeopleGroupLocation.objects.create(**location) people_group = super(PeopleGroupSerializer, self).create(validated_data) PeopleGroupAddTeamMembersSerializer().create( @@ -572,14 +565,16 @@ def create(self, validated_data): def update(self, instance, validated_data): validated_data.pop("team", {}) validated_data.pop("featured_projects", []) - location = validated_data.pop("location") + location_data = validated_data.pop("location", None) - if not location and getattr(instance, "location", None): + if location_data: + location, _ = PeopleGroupLocation.objects.update_or_create( + pk=location_data.get("id"), defaults=location_data + ) + instance.location = location + elif location_data is None and instance.location: instance.location.delete() - validated_data["location"] = None - elif location: - location.save() - validated_data["location"] = location + instance.location = None people_group = super(PeopleGroupSerializer, self).update( instance, validated_data diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 8e8d5aae..b33ea7eb 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -11,7 +11,7 @@ SeedUserFactory, UserFactory, ) -from apps.accounts.models import PeopleGroup +from apps.accounts.models import PeopleGroup, PeopleGroupLocation from apps.accounts.utils import get_superadmins_group from apps.commons.models import GroupData from apps.commons.test import JwtAPITestCase, TestRoles @@ -1276,3 +1276,81 @@ def test_parent_update_on_parent_delete(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) child.refresh_from_db() self.assertEqual(child.parent, main_parent) + + def test_locations_group(self): + self.client.force_authenticate(self.superadmin) + people_group = PeopleGroupFactory(organization=self.organization) + + # create new groups with location + url = reverse( + "PeopleGroup-list", + args=(people_group.organization.code,), + ) + payload = { + "title": "my title", + "description": "description", + "location": { + "lat": 48.853183700426335, + "lng": 2.36428239939491, + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + }, + } + response = self.client.post(url, data=payload) + data = response.json() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(data["location"]["lat"], payload["location"]["lat"]) + self.assertEqual(data["location"]["lng"], payload["location"]["lng"]) + self.assertEqual(data["location"]["title"], payload["location"]["title"]) + self.assertEqual(data["location"]["type"], payload["location"]["type"]) + self.assertIsNotNone(data["location"]["id"]) + + # no locations set (from people_group factory) + url = reverse( + "PeopleGroup-detail", + args=(people_group.organization.code, people_group.pk), + ) + response = self.client.get(url) + data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(data["location"]) + + # missings fiels 'lat/lng' to patch + payload = { + "location": { + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + } + } + response = self.client.patch(url, data=payload) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + payload = { + "location": { + "lat": 48.853183700426335, + "lng": 2.36428239939491, + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + } + } + response = self.client.patch(url, data=payload) + + # locations are created + data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(data["location"]["lat"], payload["location"]["lat"]) + self.assertEqual(data["location"]["lng"], payload["location"]["lng"]) + self.assertEqual(data["location"]["title"], payload["location"]["title"]) + self.assertEqual(data["location"]["type"], payload["location"]["type"]) + self.assertIsNotNone(data["location"]["id"]) + + location_pk = data["location"]["id"] + payload = {"location": None} + response = self.client.patch(url, data=payload) + + # locations are created + data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(data["location"]) + # objects are deleted + self.assertFalse(PeopleGroupLocation.objects.filter(pk=location_pk).exists()) diff --git a/apps/commons/serializers.py b/apps/commons/serializers.py index e040ab44..008dfcf3 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db.models import Model, Q +from django.utils.translation import gettext_lazy as _ from modeltranslation.manager import get_translatable_fields_for_model from rest_framework import mixins, serializers, viewsets from rest_framework.settings import import_from_string @@ -247,3 +248,17 @@ def get_related_organizations(self) -> list[Organization]: if "project" in self.validated_data: return self.validated_data["project"].get_related_organizations() return [] + + def _check_gis(self, value): + """check gps coord num""" + if -90 <= value <= 90: + raise serializers.ValidationError( + _("The value must be between -90 and 90.") + ) + return value + + def valiate_lat(self, value): + return self._check_gis(super().validate_lat(value)) + + def valiate_lng(self, value): + return self._check_gis(super().validate_lng(value)) diff --git a/apps/modules/group.py b/apps/modules/group.py index 2fb185f7..196721fe 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -2,7 +2,7 @@ from apps.accounts.models import PeopleGroup, ProjectUser from apps.modules.base import AbstractModules, register_module -from apps.projects.models import Project +from apps.projects.models import Location, Project from apps.skills.models import Skill from services.crisalid.models import Document, DocumentTypeCentralized diff --git a/apps/projects/migrations/0002_alter_location_type.py b/apps/projects/migrations/0002_alter_location_type.py new file mode 100644 index 00000000..553e2ac8 --- /dev/null +++ b/apps/projects/migrations/0002_alter_location_type.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.11 on 2026-02-12 09:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="location", + name="type", + field=models.CharField( + choices=[ + ("team", "Team"), + ("impact", "Impact"), + ("address", "Address"), + ], + default="team", + max_length=10, + ), + ), + ] From 7a7698d81c6a3cc1c4ee6c51acde98a96294e5d5 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 12:15:07 +0100 Subject: [PATCH 24/33] linter --- apps/accounts/admin.py | 2 +- apps/accounts/serializers.py | 35 ++++++++++++++++------------------- apps/accounts/views.py | 20 ++++++++++---------- apps/files/models.py | 2 +- apps/projects/models.py | 4 ++-- 5 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index f1c642e6..c43c3ab0 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -8,12 +8,12 @@ from django.utils.html import format_html from django.utils.safestring import mark_safe from import_export.admin import ExportActionMixin # type: ignore -from services.keycloak.interface import KeycloakService from apps.commons.admin import RoleBasedAccessAdmin, TranslateObjectAdminMixin from apps.emailing.models import Email from apps.organizations.models import Organization from apps.projects.models import Project +from services.keycloak.interface import KeycloakService from .exports import UserResource from .models import PeopleGroup, PeopleGroupLocation, ProjectUser diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 93dd8f6e..a387fa22 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -131,11 +131,11 @@ class Meta: ] fields = read_only_fields - def get_profile_picture(self, instance: ProjectUser) -> Optional[Dict[str, Any]]: + def get_profile_picture(self, instance: ProjectUser) -> dict[str, Any] | None: image = instance.profile_picture return ImageSerializer(image).data if image else None - def to_representation(self, instance: ProjectUser) -> Dict[str, Any]: + def to_representation(self, instance: ProjectUser) -> dict[str, Any]: request = self.context.get("request") force_display = self.context.get("force_display", False) if force_display or ( @@ -200,7 +200,7 @@ def to_representation(self, instance): "current_org_role": None, } - def get_profile_picture(self, user: ProjectUser) -> Union[Dict, str]: + def get_profile_picture(self, user: ProjectUser) -> dict | str: if user.profile_picture is None: return None return ImageSerializer(user.profile_picture).data @@ -220,16 +220,16 @@ def get_people_groups(self, user: ProjectUser) -> list: queryset, many=True, context=self.context ).data - def get_skills(self, user: ProjectUser) -> List[Dict]: + def get_skills(self, user: ProjectUser) -> list[dict]: return SkillLightSerializer(user.skills.all(), many=True).data - def get_needs_mentor_on(self, user: ProjectUser) -> List[Dict]: + def get_needs_mentor_on(self, user: ProjectUser) -> list[dict]: if getattr(user, "needs_mentor_on", None): skills = Skill.objects.filter(id__in=user.needs_mentor_on) return SkillLightSerializer(skills, many=True).data return [] - def get_can_mentor_on(self, user: ProjectUser) -> List[Dict]: + def get_can_mentor_on(self, user: ProjectUser) -> list[dict]: if getattr(user, "can_mentor_on", None): skills = Skill.objects.filter(id__in=user.can_mentor_on) return SkillLightSerializer(skills, many=True).data @@ -410,7 +410,7 @@ class PeopleGroupAddFeaturedProjectsSerializer(serializers.Serializer): many=True, write_only=True, required=False, queryset=Project.objects.all() ) - def validate_featured_projects(self, projects: List[Project]) -> List[Project]: + 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 @@ -445,7 +445,7 @@ class PeopleGroupSerializer( serializers.ModelSerializer, ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "name", "description", "short_description", @@ -481,7 +481,7 @@ class PeopleGroupSerializer( ) location = PeopleGroupLocationSerializer(required=False, allow_null=True) - def get_hierarchy(self, obj: PeopleGroup) -> List[Dict[str, Union[str, int]]]: + def get_hierarchy(self, obj: PeopleGroup) -> list[dict[str, str | int]]: request = self.context.get("request") queryset = request.user.get_people_group_queryset() hierarchy = [] @@ -576,10 +576,7 @@ def update(self, instance, validated_data): instance.location.delete() instance.location = None - people_group = super(PeopleGroupSerializer, self).update( - instance, validated_data - ) - return people_group + return super(PeopleGroupSerializer, self).update(instance, validated_data) class Meta: model = PeopleGroup @@ -619,7 +616,7 @@ class Meta: class UserSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "description", "short_description", "job", @@ -786,7 +783,7 @@ def _validate_role( self, group: Group, request_user: ProjectUser, - instance: Optional[HasPermissionsSetup] = None, + instance: HasPermissionsSetup | None = None, ): instance = instance or get_instance_from_group(group) if not instance or ( @@ -813,7 +810,7 @@ def _validate_role( ): raise UserRolePermissionDeniedError(group.name) - def validate_roles(self, groups: List[Group]) -> List[Group]: + def validate_roles(self, groups: list[Group]) -> list[Group]: request = self.context.get("request") user = request.user groups_to_add = ( @@ -859,13 +856,13 @@ def validate_roles(self, groups: List[Group]) -> List[Group]: ) ) - def get_permissions(self, user: ProjectUser) -> List[str]: + def get_permissions(self, user: ProjectUser) -> list[str]: return user.get_instance_permissions_representations() - def get_skills(self, user: ProjectUser) -> List[Dict]: + def get_skills(self, user: ProjectUser) -> list[dict]: return SkillLightSerializer(user.skills.all(), many=True).data - def get_profile_picture(self, user: ProjectUser) -> Optional[Dict]: + def get_profile_picture(self, user: ProjectUser) -> dict | None: if user.profile_picture is None: return None return ImageSerializer(user.profile_picture).data diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 701b0fa5..393270e4 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -32,16 +32,6 @@ from rest_framework.response import Response from rest_framework.serializers import BooleanField from rest_framework.views import APIView -from services.google.models import GoogleAccount, GoogleGroup -from services.google.tasks import ( - create_google_account, - create_google_group, - suspend_google_account, - update_google_account, - update_google_group, -) -from services.keycloak.exceptions import KeycloakAccountNotFound -from services.keycloak.interface import KeycloakService from apps.commons.filters import UnaccentSearchFilter from apps.commons.models import GroupData @@ -58,6 +48,16 @@ from apps.organizations.permissions import HasOrganizationPermission from apps.projects.serializers import LocationSerializer, ProjectLightSerializer from apps.skills.models import Skill +from services.google.models import GoogleAccount, GoogleGroup +from services.google.tasks import ( + create_google_account, + create_google_group, + suspend_google_account, + update_google_account, + update_google_group, +) +from services.keycloak.exceptions import KeycloakAccountNotFound +from services.keycloak.interface import KeycloakService from .exceptions import EmailTypeMissingError, PermissionNotFoundError from .filters import PeopleGroupFilter, UserFilter diff --git a/apps/files/models.py b/apps/files/models.py index e5c0c4de..adbf4963 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -10,7 +10,6 @@ from django.db import models, transaction from django.db.models import ForeignObjectRel, Model, Q, QuerySet from django.utils import timezone -from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords from stdimage import StdImageField @@ -20,6 +19,7 @@ OrganizationRelated, ProjectRelated, ) +from services.translator.mixins import HasAutoTranslatedFields from .enums import AttachmentLinkCategory, AttachmentType from .utils import resize_and_autorotate diff --git a/apps/projects/models.py b/apps/projects/models.py index e220aff8..de360d23 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -2,7 +2,7 @@ import math import os from functools import reduce -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, Optional import shortuuid as shortuuid from django.conf import settings @@ -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 @@ -28,6 +27,7 @@ ) 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 From d80689a4345c7ed9e054661fa66b5f7d903fc7ca Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 12:23:07 +0100 Subject: [PATCH 25/33] linter --- apps/accounts/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index a387fa22..8ee899bc 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, Dict, List, Optional, Union +from typing import Any from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType From c1d055107fa328ec932d771e5df389f881224303 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 12:33:15 +0100 Subject: [PATCH 26/33] i18n --- locale/ca/LC_MESSAGES/django.po | 57 ++++++++++++++++++++++++++++++-- locale/de/LC_MESSAGES/django.po | 58 +++++++++++++++++++++++++++++++-- locale/en/LC_MESSAGES/django.po | 8 +++-- locale/es/LC_MESSAGES/django.po | 55 +++++++++++++++++++++++++++++-- locale/et/LC_MESSAGES/django.po | 55 +++++++++++++++++++++++++++++-- locale/fr/LC_MESSAGES/django.po | 43 ++++++++++++++++++++++-- locale/nl/LC_MESSAGES/django.po | 57 ++++++++++++++++++++++++++++++-- 7 files changed, 319 insertions(+), 14 deletions(-) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index cee8a286..2d10913e 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "visibilitat" @@ -190,6 +190,10 @@ msgstr "Id no vàlid \"{user_id}\" - l'objecte no existeix." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Tipus incorrecte. S'esperava un valor str, s'ha rebut {data_type}." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2957,5 +2961,54 @@ msgstr "Drets d'autor" msgid "All rights reserved." msgstr "Tots els drets reservats." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Anar al missatge" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "{value!r} does not match the format {format}." +#~ msgid_plural "{value!r} does not match the formats {formats}." +#~ msgstr[0] "" +#~ "El conjunt de consultes donat no coincideix amb el model relacionat" +#~ msgstr[1] "" +#~ "El conjunt de consultes donat no coincideix amb el model relacionat" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Missatge de" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " i " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Ja existeix un usuari amb aquest correu electrònic" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "Aquest camp és obligatori." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Ja existeix un usuari amb aquest correu electrònic" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "The inline value did not match the parent instance." +#~ msgstr "El conjunt de consultes donat no coincideix amb el model relacionat" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Projecte" + #~ msgid "main category" #~ msgstr "categoria principal" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 87df3298..19ed3b5b 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "Sichtbarkeit" @@ -193,6 +193,10 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Falscher Typ. Erwartet wurde ein String-Wert, erhalten wurde {data_type}." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2986,5 +2990,55 @@ msgstr "Urheberrecht" msgid "All rights reserved." msgstr "Alle Rechte vorbehalten." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Zur Nachricht gehen" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "{value!r} does not match the format {format}." +#~ msgid_plural "{value!r} does not match the formats {formats}." +#~ msgstr[0] "" +#~ "Das angegebene Queryset stimmt nicht mit dem zugehörigen Modell überein" +#~ msgstr[1] "" +#~ "Das angegebene Queryset stimmt nicht mit dem zugehörigen Modell überein" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Nachricht von" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " und " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "Dieses Feld ist erforderlich." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "The inline value did not match the parent instance." +#~ msgstr "" +#~ "Das angegebene Queryset stimmt nicht mit dem zugehörigen Modell überein" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Projekt" + #~ msgid "main category" #~ msgstr "Hauptkategorie" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index baf5df2c..bb4c1ead 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "" @@ -168,6 +168,10 @@ msgstr "" msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index 0f2adf6d..417d3763 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "visibilidad" @@ -191,6 +191,10 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Tipo incorrecto. Se esperaba un valor de cadena, se recibió {data_type}." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2956,5 +2960,52 @@ msgstr "Derechos de autor" msgid "All rights reserved." msgstr "Todos los derechos reservados." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Ir al mensaje" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "{value!r} does not match the format {format}." +#~ msgid_plural "{value!r} does not match the formats {formats}." +#~ msgstr[0] "El queryset proporcionado no coincide con el modelo relacionado" +#~ msgstr[1] "El queryset proporcionado no coincide con el modelo relacionado" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Mensaje de" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " y " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Ya existe un usuario con este correo electrónico" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "Este campo es obligatorio." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Ya existe un usuario con este correo electrónico" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "The inline value did not match the parent instance." +#~ msgstr "El queryset proporcionado no coincide con el modelo relacionado" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Proyecto" + #~ msgid "main category" #~ msgstr "categoría principal" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index cd72143e..69b9682a 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "nähtavus" @@ -189,6 +189,10 @@ msgstr "Vigane ID \"{user_id}\" - objekti ei eksisteeri." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Vale tüüp. Oodati stringi väärtust, saadi {data_type}." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2933,5 +2937,52 @@ msgstr "Autoriõigus" msgid "All rights reserved." msgstr "Kõik õigused kaitstud." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Mine sõnumi juurde" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "{value!r} does not match the format {format}." +#~ msgid_plural "{value!r} does not match the formats {formats}." +#~ msgstr[0] "Antud päringu tulemus ei vasta seotud mudelile" +#~ msgstr[1] "Antud päringu tulemus ei vasta seotud mudelile" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Sõnumi postitas" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " ja " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Selle e-posti aadressiga kasutaja on juba olemas" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "See väli on kohustuslik." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Selle e-posti aadressiga kasutaja on juba olemas" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "The inline value did not match the parent instance." +#~ msgstr "Antud päringu tulemus ei vasta seotud mudelile" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Projekt" + #~ msgid "main category" #~ msgstr "peamine kategooria" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index d0d15445..4b9d2dc3 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "visibilité" @@ -192,6 +192,10 @@ msgstr "identifiant invalide \"{user_id}\" - cet objet n'existe pas." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Type incorrect. Valeur str attendue, {data_type} reçue." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2959,5 +2963,40 @@ msgstr "Droit d'auteur" msgid "All rights reserved." msgstr "Tous droits réservés." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Voir le message" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Message de" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " et " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Un·e utilisateur·rice avec cette adresse email existe déjà" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "Ce champ est obligatoire." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Un·e utilisateur·rice avec cette adresse email existe déjà" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Projet" + #~ msgid "main category" #~ msgstr "catégorie principale" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 335048c7..c5631006 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-02-10 17:21+0100\n" +"POT-Creation-Date: 2026-02-12 12:33+0100\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:144 apps/projects/models.py:161 +#: apps/accounts/models.py:148 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" @@ -192,6 +192,10 @@ msgstr "Ongeldige id \"{user_id}\" - object bestaat niet." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Onjuist type. Verwachte stringwaarde, ontvangen {data_type}." +#: apps/commons/serializers.py:256 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 @@ -2972,5 +2976,54 @@ msgstr "Auteursrecht" msgid "All rights reserved." msgstr "Alle rechten voorbehouden." +#, fuzzy, python-brace-format +#~| msgid "Go to message" +#~ msgid "Error: {message}" +#~ msgstr "Ga naar bericht" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "{value!r} does not match the format {format}." +#~ msgid_plural "{value!r} does not match the formats {formats}." +#~ msgstr[0] "" +#~ "De opgegeven queryset komt niet overeen met het gerelateerde model" +#~ msgstr[1] "" +#~ "De opgegeven queryset komt niet overeen met het gerelateerde model" + +#, fuzzy +#~| msgid "Message by" +#~ msgid "Messages" +#~ msgstr "Bericht door" + +#, fuzzy +#~| msgid " and " +#~ msgid "and" +#~ msgstr " en " + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_labels)s already exists." +#~ msgstr "Een gebruiker met dit e-mailadres bestaat al" + +#, fuzzy +#~| msgid "This field is required." +#~ msgid "This field cannot be null." +#~ msgstr "Dit veld is verplicht." + +#, fuzzy, python-format +#~| msgid "A user with this email already exists" +#~ msgid "%(model_name)s with this %(field_label)s already exists." +#~ msgstr "Een gebruiker met dit e-mailadres bestaat al" + +#, fuzzy +#~| msgid "The given queryset does not match the related model" +#~ msgid "The inline value did not match the parent instance." +#~ msgstr "De opgegeven queryset komt niet overeen met het gerelateerde model" + +#, fuzzy +#~| msgid "Project" +#~ msgid "oct" +#~ msgstr "Project" + #~ msgid "main category" #~ msgstr "hoofdcategorie" From 7f445fc1089d794e7ff3e28677666aef937fa56d Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 15:47:34 +0100 Subject: [PATCH 27/33] fix locations tests --- apps/accounts/factories.py | 19 +++- apps/accounts/models.py | 5 +- apps/commons/serializers.py | 7 -- apps/projects/models.py | 8 +- .../tests/views/test_read_location.py | 91 ++++++++++++++----- 5 files changed, 92 insertions(+), 38 deletions(-) diff --git a/apps/accounts/factories.py b/apps/accounts/factories.py index 61440c4a..904d7501 100644 --- a/apps/accounts/factories.py +++ b/apps/accounts/factories.py @@ -9,7 +9,13 @@ from services.keycloak.factories import KeycloakAccountFactory from services.keycloak.interface import KeycloakService -from .models import PeopleGroup, PrivacySettings, ProjectUser, UserScore +from .models import ( + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, + UserScore, +) faker = Faker() @@ -121,3 +127,14 @@ def create(cls, **kwargs): def with_leader(self, create, extracted, **kwargs): if create and extracted is True: UserFactory(groups=[self.get_leaders()]) + + +class PeopleGroupLocationFactory(factory.django.DjangoModelFactory): + title = factory.Faker("sentence") + description = factory.Faker("text") + lat = factory.Faker("latitude") + lng = factory.Faker("longitude") + type = PeopleGroupLocation.LocationType.ADDRESS.value + + class Meta: + model = PeopleGroupLocation diff --git a/apps/accounts/models.py b/apps/accounts/models.py index a8f40cea..cc7f7c23 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -42,9 +42,12 @@ from services.translator.mixins import HasAutoTranslatedFields -class PeopleGroupLocation(AbstractLocation): +class PeopleGroupLocation(OrganizationRelated, AbstractLocation): """base location for group""" + def get_related_organizations(self) -> list["Organization"]: + return [self.people_group.organization] + class PeopleGroup( HasEmbending, diff --git a/apps/commons/serializers.py b/apps/commons/serializers.py index 008dfcf3..0e7a2dc0 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -228,7 +228,6 @@ def save(self, **kwargs): class BaseLocationSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, - OrganizationRelatedSerializer, serializers.ModelSerializer, ): string_images_forbid_fields: list[str] = ["title", "description"] @@ -243,12 +242,6 @@ class Meta: "type", ] - def get_related_organizations(self) -> list[Organization]: - """Retrieve the related organizations""" - if "project" in self.validated_data: - return self.validated_data["project"].get_related_organizations() - return [] - def _check_gis(self, value): """check gps coord num""" if -90 <= value <= 90: diff --git a/apps/projects/models.py b/apps/projects/models.py index de360d23..a3ca415a 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -897,10 +897,6 @@ class Meta: default=LocationType.TEAM, ) - def get_related_organizations(self) -> list["Organization"]: - """Return the organizations related to this model.""" - return self.project.get_related_organizations() - # TODO(remi): rename to ProjectLocation ? class Location(ProjectRelated, AbstractLocation): @@ -922,6 +918,10 @@ def get_related_project(self) -> Optional["Project"]: """Return the projects related to this model.""" return self.project + def get_related_organizations(self) -> list["Organization"]: + """Return the organizations related to this model.""" + return self.project.get_related_organizations() + class ProjectMessage( HasAutoTranslatedFields, diff --git a/apps/projects/tests/views/test_read_location.py b/apps/projects/tests/views/test_read_location.py index 8891eaee..8dcb0d14 100644 --- a/apps/projects/tests/views/test_read_location.py +++ b/apps/projects/tests/views/test_read_location.py @@ -2,6 +2,10 @@ from parameterized import parameterized from rest_framework import status +from apps.accounts.factories import ( + PeopleGroupFactory, + PeopleGroupLocationFactory, +) from apps.commons.test import JwtAPITestCase, TestRoles from apps.organizations.factories import OrganizationFactory from apps.projects.factories import LocationFactory, ProjectFactory @@ -43,6 +47,41 @@ def setUpTestData(cls): "child": LocationFactory(project=cls.child_project), } + cls.public_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PUBLIC, + organization=cls.organization, + location=PeopleGroupLocationFactory(), + ) + cls.org_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.ORG, + organization=cls.organization, + location=PeopleGroupLocationFactory(), + ) + cls.private_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PRIVATE, + organization=cls.organization, + location=PeopleGroupLocationFactory(), + ) + cls.child_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PUBLIC, + organization=cls.organization, + location=PeopleGroupLocationFactory(), + ) + + cls.groups = { + "public": cls.public_group, + "org": cls.org_group, + "private": cls.private_group, + "child": cls.child_group, + } + + cls.locations_group = { + "public": cls.public_group.location, + "org": cls.org_group.location, + "private": cls.private_group.location, + "child": cls.child_group.location, + } + @parameterized.expand( [ (TestRoles.ANONYMOUS, ("public", "child")), @@ -57,20 +96,23 @@ def setUpTestData(cls): (TestRoles.PROJECT_REVIEWER, ("public", "org", "private", "child")), ] ) - def test_retrieve_location(self, role, retrieved_locations): - for publication_status, location in self.locations.items(): - project = location.project - user = self.get_parameterized_test_user(role, instances=[project]) - self.client.force_authenticate(user) - response = self.client.get( - reverse("Read-location-detail", args=(location.id,)), - ) - if publication_status in retrieved_locations: - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json() - self.assertEqual(content["id"], location.id) - else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_list_project_location(self, role, retrieved_locations): + user = self.get_parameterized_test_user( + role, instances=[*self.projects.values()] + ) + self.client.force_authenticate(user) + response = self.client.get( + reverse("General-location-list", args=(self.organization.code,)), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + # projects + self.assertEqual(len(content["projects"]), len(retrieved_locations)) + self.assertSetEqual( + {a["id"] for a in content["projects"]}, + {a.id for a in [self.locations[a] for a in retrieved_locations]}, + ) @parameterized.expand( [ @@ -81,23 +123,22 @@ def test_retrieve_location(self, role, retrieved_locations): (TestRoles.ORG_FACILITATOR, ("public", "org", "private", "child")), (TestRoles.ORG_USER, ("public", "org", "child")), (TestRoles.ORG_VIEWER, ("public", "org", "child")), - (TestRoles.PROJECT_MEMBER, ("public", "org", "private", "child")), - (TestRoles.PROJECT_OWNER, ("public", "org", "private", "child")), - (TestRoles.PROJECT_REVIEWER, ("public", "org", "private", "child")), + (TestRoles.GROUP_MEMBER, ("public", "org", "private", "child")), + (TestRoles.GROUP_LEADER, ("public", "org", "private", "child")), + (TestRoles.GROUP_MANAGER, ("public", "org", "private", "child")), ] ) - def test_list_location(self, role, retrieved_locations): - user = self.get_parameterized_test_user( - role, instances=[*self.projects.values()] - ) + def test_list_group_location(self, role, retrieved_locations): + user = self.get_parameterized_test_user(role, instances=[*self.groups.values()]) self.client.force_authenticate(user) response = self.client.get( - reverse("Read-location-list"), + reverse("General-location-list", args=(self.organization.code,)), ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json() - self.assertEqual(len(content), len(retrieved_locations)) + + self.assertEqual(len(content["groups"]), len(retrieved_locations)) self.assertSetEqual( - {a["id"] for a in content}, - {a.id for a in [self.locations[a] for a in retrieved_locations]}, + {a["id"] for a in content["groups"]}, + {a.id for a in [self.locations_group[a] for a in retrieved_locations]}, ) From 2a4feccfec7c76b0d8fb3fcfd99820f42bf27529 Mon Sep 17 00:00:00 2001 From: rgermain Date: Thu, 12 Feb 2026 15:58:18 +0100 Subject: [PATCH 28/33] i18n --- locale/ca/LC_MESSAGES/django.po | 6 +++--- locale/de/LC_MESSAGES/django.po | 6 +++--- locale/en/LC_MESSAGES/django.po | 6 +++--- locale/es/LC_MESSAGES/django.po | 6 +++--- locale/et/LC_MESSAGES/django.po | 6 +++--- locale/fr/LC_MESSAGES/django.po | 6 +++--- locale/nl/LC_MESSAGES/django.po | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 2d10913e..cd88c09a 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "visibilitat" @@ -190,7 +190,7 @@ msgstr "Id no vàlid \"{user_id}\" - l'objecte no existeix." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Tipus incorrecte. S'esperava un valor str, s'ha rebut {data_type}." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 19ed3b5b..d4838956 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "Sichtbarkeit" @@ -193,7 +193,7 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Falscher Typ. Erwartet wurde ein String-Wert, erhalten wurde {data_type}." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index bb4c1ead..e209ef0a 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "" @@ -168,7 +168,7 @@ msgstr "" msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index 417d3763..2607917d 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "visibilidad" @@ -191,7 +191,7 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Tipo incorrecto. Se esperaba un valor de cadena, se recibió {data_type}." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 69b9682a..71eeb06a 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "nähtavus" @@ -189,7 +189,7 @@ msgstr "Vigane ID \"{user_id}\" - objekti ei eksisteeri." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Vale tüüp. Oodati stringi väärtust, saadi {data_type}." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 4b9d2dc3..64d897c1 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "visibilité" @@ -192,7 +192,7 @@ msgstr "identifiant invalide \"{user_id}\" - cet objet n'existe pas." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Type incorrect. Valeur str attendue, {data_type} reçue." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index c5631006..363267df 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-02-12 12:33+0100\n" +"POT-Creation-Date: 2026-02-12 15:58+0100\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:148 apps/projects/models.py:161 +#: apps/accounts/models.py:151 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" @@ -192,7 +192,7 @@ msgstr "Ongeldige id \"{user_id}\" - object bestaat niet." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Onjuist type. Verwachte stringwaarde, ontvangen {data_type}." -#: apps/commons/serializers.py:256 +#: apps/commons/serializers.py:249 msgid "The value must be between -90 and 90." msgstr "" From c01404319cbecfca5521e4a98563f772342498a7 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 18 Feb 2026 17:43:50 +0100 Subject: [PATCH 29/33] fix admin --- apps/accounts/admin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index 3fb3edfb..5bde5f99 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -171,9 +171,12 @@ class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): @admin.register(PeopleGroupLocation) class PeopleGroupLocationAdmin(admin.ModelAdmin): - list_display = ("title", "description", "type", "group") - list_display_links = ("group",) - search_fields = ("title", "description", "type", "group__title") + list_display = ("title", "description", "type", "people_group") + list_display_links = ( + list_display[0], + "people_group", + ) + search_fields = ("title", "description", "type", "people_group__title") @admin.register(Permission) From 2f95c8050fe11dd8cd7da3ec08bdc96fec6c98a4 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 20 Feb 2026 12:32:25 +0100 Subject: [PATCH 30/33] change OneToOne to Fk --- ...ocation.py => 0004_peoplegrouplocation.py} | 22 +- apps/accounts/models.py | 13 +- apps/accounts/serializers.py | 54 ++-- .../accounts/tests/views/test_people_group.py | 232 +++++++++++++----- apps/accounts/urls.py | 10 +- apps/accounts/views.py | 49 +++- apps/modules/group.py | 2 +- .../tests/views/test_read_location.py | 12 +- apps/projects/views.py | 9 +- locale/ca/LC_MESSAGES/django.po | 4 +- locale/de/LC_MESSAGES/django.po | 4 +- locale/en/LC_MESSAGES/django.po | 4 +- locale/es/LC_MESSAGES/django.po | 4 +- locale/et/LC_MESSAGES/django.po | 4 +- locale/fr/LC_MESSAGES/django.po | 4 +- locale/nl/LC_MESSAGES/django.po | 4 +- 16 files changed, 288 insertions(+), 143 deletions(-) rename apps/accounts/migrations/{0004_peoplegrouplocation_peoplegroup_location.py => 0004_peoplegrouplocation.py} (87%) diff --git a/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py b/apps/accounts/migrations/0004_peoplegrouplocation.py similarity index 87% rename from apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py rename to apps/accounts/migrations/0004_peoplegrouplocation.py index 53529395..a749043f 100644 --- a/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py +++ b/apps/accounts/migrations/0004_peoplegrouplocation.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.11 on 2026-02-12 09:28 +# Generated by Django 5.2.11 on 2026-02-20 11:31 import apps.commons.mixins import django.db.models.deletion @@ -63,25 +63,23 @@ class Migration(migrations.Migration): ("description_et", models.TextField(blank=True, null=True)), ("description_ca", models.TextField(blank=True, null=True)), ("description_es", models.TextField(blank=True, null=True)), + ( + "people_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="locations", + to="accounts.peoplegroup", + ), + ), ], options={ "abstract": False, }, bases=( + apps.commons.mixins.OrganizationRelated, services.translator.mixins.HasAutoTranslatedFields, apps.commons.mixins.DuplicableModel, models.Model, ), ), - migrations.AddField( - model_name="peoplegroup", - name="location", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="people_group", - to="accounts.peoplegrouplocation", - ), - ), ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 919a202d..f0197a07 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -45,6 +45,12 @@ class PeopleGroupLocation(OrganizationRelated, AbstractLocation): """base location for group""" + people_group = models.ForeignKey( + "accounts.PeopleGroup", + on_delete=models.CASCADE, + related_name="locations", + ) + def get_related_organizations(self) -> list["Organization"]: return [self.people_group.organization] @@ -156,13 +162,6 @@ class PublicationStatus(models.TextChoices): permissions_up_to_date = models.BooleanField(default=False) tags = models.ManyToManyField("skills.Tag", related_name="people_groups") - location = models.OneToOneField( - PeopleGroupLocation, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="people_group", - ) def __str__(self) -> str: return str(self.name) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index e4fcb596..d2fd0e41 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -236,11 +236,6 @@ def get_can_mentor_on(self, user: ProjectUser) -> list[dict]: return [] -class PeopleGroupLocationSerializer(BaseLocationSerializer): - class Meta(BaseLocationSerializer.Meta): - model = PeopleGroupLocation - - class PeopleGroupSuperLightSerializer( AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -252,6 +247,20 @@ class Meta: fields = read_only_fields +class PeopleGroupLocationSerializer(BaseLocationSerializer): + people_group = serializers.PrimaryKeyRelatedField( + queryset=PeopleGroup.objects.all() + ) + + class Meta(BaseLocationSerializer.Meta): + model = PeopleGroupLocation + fields = (*BaseLocationSerializer.Meta.fields, "people_group") + + +class PeopleGroupLocationSuperLightSerializer(PeopleGroupLocationSerializer): + people_group = PeopleGroupSuperLightSerializer(read_only=True) + + class PeopleGroupLightSerializer( ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -478,7 +487,7 @@ class PeopleGroupSerializer( child=serializers.IntegerField(min_value=1, max_value=17), required=False, ) - location = PeopleGroupLocationSerializer(required=False, allow_null=True) + locations = PeopleGroupLocationSerializer(read_only=True, many=True) def get_hierarchy(self, obj: PeopleGroup) -> list[dict[str, str | int]]: request = self.context.get("request") @@ -537,20 +546,9 @@ def validate_parent(self, value): parent = parent.parent return value - def validate_location(self, values): - location_serializer = PeopleGroupLocationSerializer( - data=values, allow_null=True - ) - location_serializer.is_valid(raise_exception=True) - return location_serializer.validated_data - def create(self, validated_data): team = validated_data.pop("team", {}) featured_projects = validated_data.pop("featured_projects", []) - location = validated_data.pop("location", None) - - if location is not None: - validated_data["location"] = PeopleGroupLocation.objects.create(**location) people_group = super(PeopleGroupSerializer, self).create(validated_data) PeopleGroupAddTeamMembersSerializer().create( @@ -564,16 +562,6 @@ def create(self, validated_data): def update(self, instance, validated_data): validated_data.pop("team", {}) validated_data.pop("featured_projects", []) - location_data = validated_data.pop("location", None) - - if location_data: - location, _ = PeopleGroupLocation.objects.update_or_create( - pk=location_data.get("id"), defaults=location_data - ) - instance.location = location - elif location_data is None and instance.location: - instance.location.delete() - instance.location = None return super(PeopleGroupSerializer, self).update(instance, validated_data) @@ -594,23 +582,13 @@ class Meta: "roles", "sdgs", "tags", - "location", + "locations", "publication_status", "team", "featured_projects", ] -class LocationPeopleGroupSerializer( - AutoTranslatedModelSerializer, serializers.ModelSerializer -): - group = PeopleGroupSuperLightSerializer(source="people_group", read_only=True) - - class Meta: - model = PeopleGroupLocation - fields = "__all__" - - @extend_schema_serializer(exclude_fields=("roles",)) class UserSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index b33ea7eb..56db30ee 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -1277,80 +1277,202 @@ def test_parent_update_on_parent_delete(self): child.refresh_from_db() self.assertEqual(child.parent, main_parent) - def test_locations_group(self): - self.client.force_authenticate(self.superadmin) - people_group = PeopleGroupFactory(organization=self.organization) - # create new groups with location +class TestPeopleGroupLocation(JwtAPITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.organization = OrganizationFactory() + cls.people_group = PeopleGroupFactory(organization=cls.organization) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_200_OK), + (TestRoles.DEFAULT, status.HTTP_200_OK), + (TestRoles.SUPERADMIN, status.HTTP_200_OK), + (TestRoles.ORG_ADMIN, status.HTTP_200_OK), + (TestRoles.ORG_FACILITATOR, status.HTTP_200_OK), + (TestRoles.ORG_USER, status.HTTP_200_OK), + (TestRoles.GROUP_LEADER, status.HTTP_200_OK), + (TestRoles.GROUP_MANAGER, status.HTTP_200_OK), + (TestRoles.GROUP_MEMBER, status.HTTP_200_OK), + ] + ) + def test_list(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + + # no locations set (from people_group factory) url = reverse( - "PeopleGroup-list", - args=(people_group.organization.code,), + "PeopleGroup-detail", + args=(self.people_group.organization.code, self.people_group.pk), ) + response = self.client.get(url) + data = response.json() + self.assertEqual(response.status_code, role_status_code) + self.assertEqual(data["locations"], []) + payload = { - "title": "my title", + "title": "title", "description": "description", - "location": { - "lat": 48.853183700426335, - "lng": 2.36428239939491, - "title": "", - "type": PeopleGroupLocation.LocationType.ADDRESS.value, - }, + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, } - response = self.client.post(url, data=payload) + PeopleGroupLocation.objects.create(people_group=self.people_group, **payload) + + response = self.client.get(url) data = response.json() - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(data["location"]["lat"], payload["location"]["lat"]) - self.assertEqual(data["location"]["lng"], payload["location"]["lng"]) - self.assertEqual(data["location"]["title"], payload["location"]["title"]) - self.assertEqual(data["location"]["type"], payload["location"]["type"]) - self.assertIsNotNone(data["location"]["id"]) + self.assertEqual(response.status_code, role_status_code) + self.assertEqual(len(data["locations"]), 1) + self.assertEqual(data["locations"][0]["lat"], payload["lat"]) + self.assertEqual(data["locations"][0]["lng"], payload["lng"]) + self.assertEqual(data["locations"][0]["title"], payload["title"]) + self.assertEqual(data["locations"][0]["type"], payload["type"]) + self.assertIsNotNone(data["locations"][0]["id"]) + self.assertEqual(data["locations"][0]["people_group"], self.people_group.id) - # no locations set (from people_group factory) + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_201_CREATED), + (TestRoles.ORG_ADMIN, status.HTTP_201_CREATED), + (TestRoles.ORG_FACILITATOR, status.HTTP_201_CREATED), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MANAGER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_create(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) url = reverse( - "PeopleGroup-detail", - args=(people_group.organization.code, people_group.pk), + "PeopleGroupLocations-list", + args=(self.people_group.organization.code, self.people_group.pk), ) - response = self.client.get(url) + + payload = { + "lat": 48.853183700426335, + "lng": 2.36428239939491, + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + } + response = self.client.post(url, data=payload) data = response.json() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNone(data["location"]) + self.assertEqual(response.status_code, role_status_code) + + # no need to check more + if role_status_code != status.HTTP_201_CREATED: + return + + self.assertEqual(data["lat"], payload["lat"]) + self.assertEqual(data["lng"], payload["lng"]) + self.assertEqual(data["title"], payload["title"]) + self.assertEqual(data["type"], payload["type"]) + self.assertIsNotNone(data["id"]) + self.assertEqual(data["people_group"], self.people_group.id) # missings fiels 'lat/lng' to patch payload = { - "location": { - "title": "", - "type": PeopleGroupLocation.LocationType.ADDRESS.value, - } + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, } - response = self.client.patch(url, data=payload) + response = self.client.post(url, data=payload) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_200_OK), + (TestRoles.ORG_ADMIN, status.HTTP_201_CREATED), + (TestRoles.ORG_FACILITATOR, status.HTTP_201_CREATED), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MANAGER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_update(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) payload = { - "location": { - "lat": 48.853183700426335, - "lng": 2.36428239939491, - "title": "", - "type": PeopleGroupLocation.LocationType.ADDRESS.value, - } + "title": "title", + "description": "description", + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, } - response = self.client.patch(url, data=payload) + location = PeopleGroupLocation.objects.create( + people_group=self.people_group, **payload + ) + url = reverse( + "PeopleGroupLocations-detail", + args=( + self.people_group.organization.code, + self.people_group.id, + location.id, + ), + ) - # locations are created - data = response.json() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(data["location"]["lat"], payload["location"]["lat"]) - self.assertEqual(data["location"]["lng"], payload["location"]["lng"]) - self.assertEqual(data["location"]["title"], payload["location"]["title"]) - self.assertEqual(data["location"]["type"], payload["location"]["type"]) - self.assertIsNotNone(data["location"]["id"]) - - location_pk = data["location"]["id"] - payload = {"location": None} + # partial update + payload = { + "lat": 66, + } response = self.client.patch(url, data=payload) - - # locations are created data = response.json() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNone(data["location"]) - # objects are deleted - self.assertFalse(PeopleGroupLocation.objects.filter(pk=location_pk).exists()) + self.assertEqual(response.status_code, role_status_code) + if role_status_code == status.HTTP_200_OK: + self.assertEqual(data["lat"], payload["lat"]) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_ADMIN, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_FACILITATOR, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_204_NO_CONTENT), + (TestRoles.GROUP_MANAGER, status.HTTP_204_NO_CONTENT), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_delete(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + payload = { + "title": "title", + "description": "description", + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, + } + location = PeopleGroupLocation.objects.create( + people_group=self.people_group, **payload + ) + url = reverse( + "PeopleGroupLocations-detail", + args=( + self.people_group.organization.code, + self.people_group.id, + location.id, + ), + ) + + response = self.client.delete(url) + self.assertEqual(response.status_code, role_status_code) + if role_status_code == status.HTTP_204_NO_CONTENT: + self.assertFalse( + PeopleGroupLocation.objects.filter(pk=location.id).exists() + ) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 7026bf83..784100e4 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -4,11 +4,15 @@ from apps.accounts.views import ( AccessTokenView, DeleteCookieView, + PeopleGroupLocationViewSet, PrivacySettingsViewSet, UserProfilePictureView, UserViewSet, ) -from apps.commons.urls import user_router_register +from apps.commons.urls import ( + organization_people_group_router_register, + user_router_register, +) from apps.feedbacks.views import ReviewViewSet, UserFollowViewSet router = DefaultRouter() @@ -21,6 +25,10 @@ router, r"profile-picture", UserProfilePictureView, basename="UserProfilePicture" ) +organization_people_group_router_register( + router, r"locations", PeopleGroupLocationViewSet, basename="PeopleGroupLocations" +) + urlpatterns = [ path("access-token/", AccessTokenView.as_view()), diff --git a/apps/accounts/views.py b/apps/accounts/views.py index fea0e91d..b8bfce51 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -41,7 +41,12 @@ RetrieveUpdateModelViewSet, ) from apps.commons.utils import map_action_to_permission -from apps.commons.views import DetailOnlyViewsetMixin, MultipleIDViewsetMixin +from apps.commons.views import ( + DetailOnlyViewsetMixin, + MultipleIDViewsetMixin, + NestedOrganizationViewMixins, + NestedPeopleGroupViewMixins, +) from apps.files.models import Image from apps.files.views import ImageStorageView from apps.organizations.models import Organization @@ -61,7 +66,13 @@ from .exceptions import EmailTypeMissingError, PermissionNotFoundError from .filters import PeopleGroupFilter, UserFilter -from .models import AnonymousUser, PeopleGroup, PrivacySettings, ProjectUser +from .models import ( + AnonymousUser, + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, +) from .parsers import UserMultipartParser from .permissions import HasBasePermission, HasPeopleGroupPermission from .serializers import ( @@ -72,6 +83,7 @@ PeopleGroupAddTeamMembersSerializer, PeopleGroupHierarchySerializer, PeopleGroupLightSerializer, + PeopleGroupLocationSerializer, PeopleGroupRemoveFeaturedProjectsSerializer, PeopleGroupRemoveTeamMembersSerializer, PeopleGroupSerializer, @@ -851,14 +863,14 @@ def similars(self, request, *args, **kwargs): @action( detail=True, methods=["GET"], - url_path="locations", + url_path="projects_locations", permission_classes=[ReadOnly], ) def locations(self, request, *args, **kwargs): group = self.get_object() modules_manager = group.get_related_module() modules = modules_manager(group, request.user) - queryset = modules.locations() + queryset = modules.projects_locations() return Response( LocationSerializer(queryset, many=True, context={"request": request}).data, @@ -866,6 +878,35 @@ def locations(self, request, *args, **kwargs): ) +class PeopleGroupLocationViewSet( + NestedOrganizationViewMixins, NestedPeopleGroupViewMixins, viewsets.ModelViewSet +): + serializer_class = PeopleGroupLocationSerializer + + def get_permissions(self): + codename = map_action_to_permission(self.action, "peoplegroup") + if codename: + self.permission_classes = [ + IsAuthenticatedOrReadOnly, + ReadOnly + | HasBasePermission(codename, "accounts") + | HasOrganizationPermission(codename) + | HasPeopleGroupPermission(codename), + ] + return super().get_permissions() + + def get_queryset(self): + return PeopleGroupLocation.objects.filter(people_group=self.people_group) + + def update(self, request, *args, **kwargs): + request.data["people_group"] = self.people_group.id + return super().update(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + request.data["people_group"] = self.people_group.id + return super().create(request, *args, **kwargs) + + @extend_schema( parameters=[OpenApiParameter("people_group_id", str, OpenApiParameter.PATH)] ) diff --git a/apps/modules/group.py b/apps/modules/group.py index 196721fe..c1dc26ab 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -69,7 +69,7 @@ def subgroups(self) -> QuerySet[PeopleGroup]: pk__in=self.user.get_people_group_queryset() ) - def locations(self) -> QuerySet[Location]: + def featured_projects_locations(self) -> QuerySet[Location]: return Location.objects.filter(project__in=self.featured_projects()) def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: diff --git a/apps/projects/tests/views/test_read_location.py b/apps/projects/tests/views/test_read_location.py index 8dcb0d14..9315f785 100644 --- a/apps/projects/tests/views/test_read_location.py +++ b/apps/projects/tests/views/test_read_location.py @@ -50,22 +50,18 @@ def setUpTestData(cls): cls.public_group = PeopleGroupFactory( publication_status=Project.PublicationStatus.PUBLIC, organization=cls.organization, - location=PeopleGroupLocationFactory(), ) cls.org_group = PeopleGroupFactory( publication_status=Project.PublicationStatus.ORG, organization=cls.organization, - location=PeopleGroupLocationFactory(), ) cls.private_group = PeopleGroupFactory( publication_status=Project.PublicationStatus.PRIVATE, organization=cls.organization, - location=PeopleGroupLocationFactory(), ) cls.child_group = PeopleGroupFactory( publication_status=Project.PublicationStatus.PUBLIC, organization=cls.organization, - location=PeopleGroupLocationFactory(), ) cls.groups = { @@ -76,10 +72,10 @@ def setUpTestData(cls): } cls.locations_group = { - "public": cls.public_group.location, - "org": cls.org_group.location, - "private": cls.private_group.location, - "child": cls.child_group.location, + "public": PeopleGroupLocationFactory(people_group=cls.public_group), + "org": PeopleGroupLocationFactory(people_group=cls.org_group), + "private": PeopleGroupLocationFactory(people_group=cls.private_group), + "child": PeopleGroupLocationFactory(people_group=cls.child_group), } @parameterized.expand( diff --git a/apps/projects/views.py b/apps/projects/views.py index 71cf7472..74504a59 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -13,13 +13,16 @@ from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.filters import OrderingFilter -from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly +from rest_framework.permissions import ( + IsAuthenticated, + IsAuthenticatedOrReadOnly, +) from rest_framework.response import Response from simple_history.utils import update_change_reason from apps.accounts.models import PeopleGroupLocation from apps.accounts.permissions import HasBasePermission -from apps.accounts.serializers import LocationPeopleGroupSerializer +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 @@ -1016,7 +1019,7 @@ def list(self, request, *args, **kwargs): ).select_related("people_group") data = { - "groups": LocationPeopleGroupSerializer(qs_group, many=True).data, + "groups": PeopleGroupLocationSuperLightSerializer(qs_group, many=True).data, "projects": LocationSerializer(qs_project, many=True).data, } return Response(data, status=status.HTTP_200_OK) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 337a9880..39602a02 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilitat" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index b941487f..33445a72 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "Sichtbarkeit" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 068e753b..6fea3994 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-02-18 17:56+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index 6089340d..b095e10d 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilidad" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 82bd85c0..6e9b3aa3 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "nähtavus" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 113cd823..613828fe 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilité" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index bed30705..f364b45a 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-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\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:151 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" From a6207f3ae83a4f932cb36038bfa548f8cbe0a6fb Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 20 Feb 2026 14:09:47 +0100 Subject: [PATCH 31/33] change loations projects --- apps/accounts/serializers.py | 2 +- apps/accounts/views.py | 2 +- apps/modules/group.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index d2fd0e41..bc83c2a6 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -487,7 +487,7 @@ class PeopleGroupSerializer( child=serializers.IntegerField(min_value=1, max_value=17), required=False, ) - locations = PeopleGroupLocationSerializer(read_only=True, many=True) + locations = PeopleGroupLocationSerializer(many=True, read_only=True) def get_hierarchy(self, obj: PeopleGroup) -> list[dict[str, str | int]]: request = self.context.get("request") diff --git a/apps/accounts/views.py b/apps/accounts/views.py index b8bfce51..4a988010 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -863,7 +863,7 @@ def similars(self, request, *args, **kwargs): @action( detail=True, methods=["GET"], - url_path="projects_locations", + url_path="projects-locations", permission_classes=[ReadOnly], ) def locations(self, request, *args, **kwargs): diff --git a/apps/modules/group.py b/apps/modules/group.py index c1dc26ab..a9285361 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -69,7 +69,7 @@ def subgroups(self) -> QuerySet[PeopleGroup]: pk__in=self.user.get_people_group_queryset() ) - def featured_projects_locations(self) -> QuerySet[Location]: + def projects_locations(self) -> QuerySet[Location]: return Location.objects.filter(project__in=self.featured_projects()) def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: From 29443bb666d38aeebf47b5410f680f2485d51021 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 20 Feb 2026 14:31:26 +0100 Subject: [PATCH 32/33] feat: add serializers locations in create from admin pages --- apps/accounts/serializers.py | 26 ++++++++++++-- .../accounts/tests/views/test_people_group.py | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index bc83c2a6..dbbed341 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -249,7 +249,7 @@ class Meta: class PeopleGroupLocationSerializer(BaseLocationSerializer): people_group = serializers.PrimaryKeyRelatedField( - queryset=PeopleGroup.objects.all() + required=False, queryset=PeopleGroup.objects.all() ) class Meta(BaseLocationSerializer.Meta): @@ -261,6 +261,23 @@ class PeopleGroupLocationSuperLightSerializer(PeopleGroupLocationSerializer): people_group = PeopleGroupSuperLightSerializer(read_only=True) +class PeopleGroupAddLocationsSerializer(serializers.Serializer): + people_group = HiddenPrimaryKeyRelatedField( + required=False, write_only=True, queryset=PeopleGroup.objects.all() + ) + locations = PeopleGroupLocationSerializer(many=True) + + def create(self, validated_data): + people_group = validated_data["people_group"] + locations = validated_data["locations"] + + locations_objs = [ + PeopleGroupLocation(**datas, people_group=people_group) + for datas in locations + ] + return PeopleGroupLocation.objects.bulk_create(locations_objs) + + class PeopleGroupLightSerializer( ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -487,7 +504,7 @@ class PeopleGroupSerializer( child=serializers.IntegerField(min_value=1, max_value=17), required=False, ) - locations = PeopleGroupLocationSerializer(many=True, read_only=True) + locations = PeopleGroupLocationSerializer(many=True, required=False) def get_hierarchy(self, obj: PeopleGroup) -> list[dict[str, str | int]]: request = self.context.get("request") @@ -549,6 +566,7 @@ def validate_parent(self, value): def create(self, validated_data): team = validated_data.pop("team", {}) featured_projects = validated_data.pop("featured_projects", []) + locations = validated_data.pop("locations", []) people_group = super(PeopleGroupSerializer, self).create(validated_data) PeopleGroupAddTeamMembersSerializer().create( @@ -557,11 +575,15 @@ def create(self, validated_data): 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(PeopleGroupSerializer, self).update(instance, validated_data) diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 56db30ee..09abfea1 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -283,6 +283,30 @@ def test_create_people_group(self, role, expected_code): managers = self.managers leaders = self.leaders projects = self.projects + locations = [ + { + "title": "title-location", + "description": "description", + "lat": 54, + "lng": 32, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + { + "title": "title-location-2", + "description": "description-2", + "lat": 11, + "lng": 42, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + { + "title": "title-location-3", + "description": "description-3", + "lat": 65, + "lng": 52, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + ] + user = self.get_parameterized_test_user(role, instances=[organization]) self.client.force_authenticate(user) payload = { @@ -296,6 +320,7 @@ def test_create_people_group(self, role, expected_code): "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,)), @@ -319,6 +344,16 @@ def test_create_people_group(self, role, expected_code): for project in projects: self.assertIn(project, people_group.featured_projects.all()) + actual_locations = sorted( + response.data["locations"], key=lambda x: x["title"] + ) + for idx, location in enumerate(actual_locations): + self.assertEqual(location["title"], locations[idx]["title"]) + self.assertEqual(location["description"], locations[idx]["description"]) + self.assertEqual(location["lat"], locations[idx]["lat"]) + self.assertEqual(location["lng"], locations[idx]["lng"]) + self.assertEqual(location["type"], locations[idx]["type"]) + class UpdatePeopleGroupTestCase(JwtAPITestCase): @classmethod From 3334cdd8e6c5bfe80fef1607369b725ccd92cea2 Mon Sep 17 00:00:00 2001 From: rgermain Date: Fri, 20 Feb 2026 14:43:54 +0100 Subject: [PATCH 33/33] test: fix status code --- apps/accounts/tests/views/test_people_group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/accounts/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 09abfea1..7353c07a 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -1426,11 +1426,11 @@ def test_create(self, role, role_status_code): (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), (TestRoles.SUPERADMIN, status.HTTP_200_OK), - (TestRoles.ORG_ADMIN, status.HTTP_201_CREATED), - (TestRoles.ORG_FACILITATOR, status.HTTP_201_CREATED), + (TestRoles.ORG_ADMIN, status.HTTP_200_OK), + (TestRoles.ORG_FACILITATOR, status.HTTP_200_OK), (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), - (TestRoles.GROUP_LEADER, status.HTTP_201_CREATED), - (TestRoles.GROUP_MANAGER, status.HTTP_201_CREATED), + (TestRoles.GROUP_LEADER, status.HTTP_200_OK), + (TestRoles.GROUP_MANAGER, status.HTTP_200_OK), (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), ] )