diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index e429c7eb..375e3d44 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 @@ -163,6 +163,12 @@ 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") + + class PermissionAdmin(admin.ModelAdmin): list_display = ("name", "codename", "content_type") search_fields = ("name", "codename", "content_type__model") diff --git a/apps/accounts/migrations/0003_peoplegroup_tags.py b/apps/accounts/migrations/0003_peoplegroup_tags.py new file mode 100644 index 00000000..93d1b017 --- /dev/null +++ b/apps/accounts/migrations/0003_peoplegroup_tags.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.10 on 2026-01-21 06:35 + +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/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py b/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py new file mode 100644 index 00000000..f47dc9f1 --- /dev/null +++ b/apps/accounts/migrations/0004_peoplegrouplocation_peoplegroup_location.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.10 on 2026-01-21 10:52 + +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.ProjectRelated, + apps.commons.mixins.DuplicableModel, + models.Model, + ), + ), + migrations.AddField( + model_name="peoplegroup", + name="location", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="people_groups", + to="accounts.peoplegrouplocation", + ), + ), + ] diff --git a/apps/accounts/migrations/0005_alter_peoplegroup_location.py b/apps/accounts/migrations/0005_alter_peoplegroup_location.py new file mode 100644 index 00000000..29fb9b0e --- /dev/null +++ b/apps/accounts/migrations/0005_alter_peoplegroup_location.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.10 on 2026-02-02 16:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0004_peoplegrouplocation_peoplegroup_location"), + ] + + operations = [ + migrations.AlterField( + 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 0fbb1fd4..19208853 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -2,7 +2,7 @@ import uuid from datetime import date from functools import cached_property -from typing import Any, List, Optional, Union +from typing import Any, Optional from django.contrib.auth.models import AbstractUser, Group, Permission from django.contrib.contenttypes.models import ContentType @@ -25,6 +25,8 @@ ) from apps.commons.enums import SDG, Language from apps.commons.mixins import ( + HasEmbending, + HasModulesRelated, HasMultipleIDs, HasOwner, HasPermissionsSetup, @@ -33,14 +35,20 @@ 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, HasAutoTranslatedFields, HasMultipleIDs, HasPermissionsSetup, @@ -81,12 +89,12 @@ class PeopleGroup( The visibility setting of the group. """ - _auto_translated_fields: List[str] = [ + _auto_translated_fields: list[str] = [ "name", "html:description", "short_description", ] - slugified_fields: List[str] = ["name"] + slugified_fields: list[str] = ["name"] slug_prefix: str = "group" class PublicationStatus(models.TextChoices): @@ -144,6 +152,16 @@ 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") + location = models.OneToOneField( + PeopleGroupLocation, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="people_group", + ) + # links + def __str__(self) -> str: return str(self.name) @@ -156,7 +174,7 @@ def get_id_field_name(cls, object_id: Any) -> str: except ValueError: return "slug" - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return [self.organization] if self.organization else [] @@ -265,23 +283,23 @@ class ProjectUser( """ organization_query_string: str = "groups__organizations" - _auto_translated_fields: List[str] = [ + _auto_translated_fields: list[str] = [ "html:description", "short_description", "job", ] - slugified_fields: List[str] = ["given_name", "family_name"] + slugified_fields: list[str] = ["given_name", "family_name"] slug_prefix: str = "user" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._project_queryset: Optional[QuerySet["Project"]] = None - self._user_queryset: Optional[QuerySet["ProjectUser"]] = None - self._people_group_queryset: Optional[QuerySet["PeopleGroup"]] = None - self._news_queryset: Optional[QuerySet["News"]] = None - self._event_queryset: Optional[QuerySet["Event"]] = None - self._instruction_queryset: Optional[QuerySet["Instruction"]] = None - self._related_organizations: list["Organization"] = None + self._project_queryset: QuerySet[Project] | None = None + self._user_queryset: QuerySet[ProjectUser] | None = None + self._people_group_queryset: QuerySet[PeopleGroup] | None = None + self._news_queryset: QuerySet[News] | None = None + self._event_queryset: QuerySet[Event] | None = None + self._instruction_queryset: QuerySet[Instruction] | None = None + self._related_organizations: list[Organization] = None # AbstractUser unused fields username_validator = None @@ -358,7 +376,7 @@ class Meta: permissions = (("get_user_by_email", "Can retrieve a user by email"),) @property - def keycloak_id(self) -> Optional[uuid.UUID]: + def keycloak_id(self) -> uuid.UUID | None: if hasattr(self, "keycloak_account"): return str(self.keycloak_account.keycloak_id) return None @@ -384,7 +402,7 @@ def is_staff(self) -> bool: ) @classmethod - def get_id_field_name(cls, object_id: Union[uuid.UUID, int, str]) -> str: + def get_id_field_name(cls, object_id: uuid.UUID | int | str) -> str: """Get the name of the field which contains the given ID.""" try: uuid.UUID(object_id) @@ -398,7 +416,7 @@ def get_id_field_name(cls, object_id: Union[uuid.UUID, int, str]) -> str: @classmethod def get_main_id( - cls, object_id: Union[uuid.UUID, int, str], returned_field: str = "id" + cls, object_id: uuid.UUID | int | str, returned_field: str = "id" ) -> Any: try: return super().get_main_id(object_id, returned_field) @@ -470,7 +488,7 @@ def get_owner(self) -> "ProjectUser": """Get the owner of the object.""" return self - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" if self._related_organizations is None: self._related_organizations = list( @@ -678,7 +696,7 @@ def can_see_project(self, project: "Project") -> bool: """Whether the user can see the project.""" return self.get_project_queryset().contains(project) - def get_permissions_representations(self) -> List[str]: + def get_permissions_representations(self) -> list[str]: """Return a list of the permissions representations.""" groups_permissions = [ get_group_permissions(group) @@ -693,7 +711,7 @@ def get_permissions_representations(self) -> List[str]: ] return list(set(groups_permissions)) - def get_instance_permissions_representations(self) -> List[str]: + def get_instance_permissions_representations(self) -> list[str]: """Return a list of the instance permissions representations.""" groups = self.groups.exclude( projects=None, people_groups=None, organizations=None @@ -967,7 +985,7 @@ def get_permissions_representations(self): """Return a list of the permissions representations.""" return [] - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return [] diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 41e79e68..d2d34c27 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 @@ -16,14 +16,18 @@ ) from apps.commons.mixins import HasPermissionsSetup from apps.commons.models import GroupData -from apps.commons.serializers import StringsImagesSerializer +from apps.commons.serializers import ( + BaseLocationSerializer, + ModulesSerializers, + StringsImagesSerializer, +) from apps.files.models import Image from apps.files.serializers import ImageSerializer 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 @@ -37,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 @@ -121,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 ( @@ -190,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 @@ -210,22 +220,41 @@ 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 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 ): @@ -233,15 +262,14 @@ 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 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 +278,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 +289,25 @@ 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 { + "members": modules.members().count(), + "subgroups": modules.subgroups().count(), + } + class PeopleGroupHierarchySerializer( + ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer, ): @@ -292,15 +330,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 @@ -373,7 +423,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 @@ -402,10 +452,13 @@ def create(self, validated_data): class PeopleGroupSerializer( - StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer + ModulesSerializers, + StringsImagesSerializer, + AutoTranslatedModelSerializer, + serializers.ModelSerializer, ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "name", "description", "short_description", @@ -415,7 +468,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,8 +486,15 @@ 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, + ) + location = PeopleGroupLocationRelated(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 = [] @@ -447,20 +506,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 @@ -508,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} @@ -520,14 +572,23 @@ 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 - read_only_fields = ["is_root", "slug"] + read_only_fields = ["is_root", "slug", "modules"] fields = read_only_fields + [ "id", "name", @@ -537,21 +598,33 @@ class Meta: "parent", "organization", "hierarchy", - "children", "header_image", "logo_image", "roles", + "sdgs", + "tags", + "location", "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 ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "description", "short_description", "job", @@ -718,7 +791,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 ( @@ -745,7 +818,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 = ( @@ -791,13 +864,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 f537d027..9823d954 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 ( @@ -682,27 +681,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: @@ -790,26 +772,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( @@ -837,6 +803,59 @@ 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) + + @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/commons/mixins.py b/apps/commons/mixins.py index 84ef04d5..f1b2e035 100644 --- a/apps/commons/mixins.py +++ b/apps/commons/mixins.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple +from collections.abc import Iterable +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 +36,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 +92,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 +185,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. @@ -320,9 +321,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 +372,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] @@ -408,3 +409,32 @@ def get_slug(self) -> str: if self.get_id_field_name(slug) != "slug": slug = f"{self.slug_prefix}-{slug}" 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): + 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/apps/commons/serializers.py b/apps/commons/serializers.py index c6679a7a..b1f13b7b 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -1,4 +1,5 @@ -from typing import Any, Collection, Dict, List, Optional +from collections.abc import Collection +from typing import Any from django.conf import settings from django.db.models import Model, Q @@ -11,12 +12,13 @@ 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): """Base serializer for serializers related to projects.""" - def get_related_project(self) -> Optional[Project]: + def get_related_project(self) -> Project | None: """Retrieve the related projects""" raise NotImplementedError() @@ -24,7 +26,7 @@ def get_related_project(self) -> Optional[Project]: class OrganizationRelatedSerializer(serializers.ModelSerializer): """Base serializer for serializers related to organizations.""" - def get_related_organizations(self) -> List[Organization]: + def get_related_organizations(self) -> list[Organization]: """Retrieve the related organizations""" raise NotImplementedError() @@ -155,8 +157,8 @@ class StringsImagesSerializer(serializers.ModelSerializer): It replaces base64 images with uploaded image references during serialization. """ - string_images_fields: List[str] = [] - string_images_forbid_fields: List[str] = [] + string_images_fields: list[str] = [] + string_images_forbid_fields: list[str] = [] string_images_upload_to: str = "" string_images_view: str = "" string_images_process_template: bool = False @@ -164,19 +166,19 @@ class StringsImagesSerializer(serializers.ModelSerializer): def get_string_images_kwargs( self, instance: Model, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get additional kwargs for image processing based on the instance.""" return {} def get_string_images_owner( self, instance: Model, field_name: str, *args: Any, **kwargs: Any - ) -> Optional[ProjectUser]: + ) -> ProjectUser | None: """Get the owner for image processing based on the instance.""" request = self.context.get("request") return request.user if request else None def add_string_images_to_instance( - self, instance: Model, images: List["Image"] + self, instance: Model, images: list["Image"] ) -> None: """Add images to the instance's images field.""" if self.instance and images: @@ -221,3 +223,40 @@ def save(self, **kwargs): return self.instance instance = super().save(**kwargs) return self.add_string_images_to_instance(instance, images) + + +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() + + +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/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/files/migrations/0005_alter_image_options_peoplegroupimage.py b/apps/files/migrations/0005_alter_image_options_peoplegroupimage.py new file mode 100644 index 00000000..12ae550c --- /dev/null +++ b/apps/files/migrations/0005_alter_image_options_peoplegroupimage.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.10 on 2026-02-04 12:18 + +import apps.commons.mixins +import apps.files.models +import django.db.models.deletion +import stdimage.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0005_alter_peoplegroup_location"), + ("files", "0004_projectuserattachmentlink_projectuserattachmentfile_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="image", + options={"ordering": ("-created_at",)}, + ), + migrations.CreateModel( + name="PeopleGroupImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, max_length=255, null=True)), + ( + "file", + stdimage.models.StdImageField( + force_min_size=False, + height_field="height", + upload_to=apps.files.models.dynamic_upload_to, + variations={ + "full": (1920, 10000), + "large": (1024, 10000), + "medium": (768, 10000), + "small": (500, 10000), + }, + width_field="width", + ), + ), + ("height", models.IntegerField(blank=True, null=True)), + ("width", models.IntegerField(blank=True, null=True)), + ("natural_ratio", models.FloatField(blank=True, null=True)), + ("scale_x", models.FloatField(blank=True, null=True)), + ("scale_y", models.FloatField(blank=True, null=True)), + ("left", models.FloatField(blank=True, null=True)), + ("top", models.FloatField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "people_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="accounts.peoplegroup", + ), + ), + ], + options={ + "ordering": ("-created_at",), + "abstract": False, + }, + bases=( + models.Model, + apps.commons.mixins.DuplicableModel, + apps.commons.mixins.HasOwners, + ), + ), + ] diff --git a/apps/files/models.py b/apps/files/models.py index 42fa76f9..65c41d68 100644 --- a/apps/files/models.py +++ b/apps/files/models.py @@ -1,6 +1,7 @@ import datetime import uuid from contextlib import suppress +from copy import deepcopy from typing import TYPE_CHECKING, Any, Optional from azure.core.exceptions import ResourceNotFoundError @@ -16,6 +17,7 @@ from apps.commons.mixins import ( DuplicableModel, HasOwner, + HasOwners, OrganizationRelated, ProjectRelated, ) @@ -38,7 +40,7 @@ def dynamic_upload_to(instance: Model, filename: str): "argument should have a dynamic attribute `_upload_to` set before " "saving it for the first time." % instance.__class__.__name__ ) - upload_to = instance.__dict__.pop("_upload_to") + upload_to = instance.__dict__.pop("_upload_to", instance._upload_to) return upload_to(instance, filename) @@ -73,6 +75,11 @@ def user_attachment_directory_path( return f"users/attachments/{instance.owner.pk}/{instance.attachment_type}/{date_part}-{filename}" +def people_group_images_directory_path(instance: "PeopleGroupImage", filename: str): + date_part = f"{datetime.datetime.today():%Y-%m-%d}" + return f"peoplegroup/images/{instance.pk}/{date_part}-{filename}" + + class AttachmentLink( HasAutoTranslatedFields, DuplicableModel, @@ -238,10 +245,8 @@ def duplicate(self, project: "Project") -> Optional["AttachmentFile"]: return None -class Image( - models.Model, HasOwner, ProjectRelated, OrganizationRelated, DuplicableModel -): - name = models.CharField(max_length=255) +class BaseImage(models.Model, DuplicableModel): + name = models.CharField(max_length=255, null=True, blank=True) file = StdImageField( upload_to=dynamic_upload_to, height_field="height", @@ -263,6 +268,35 @@ class Image( left = models.FloatField(blank=True, null=True) top = models.FloatField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + abstract = True + + def duplicate(self, upload_to: str = "") -> Optional["BaseImage"]: + with suppress(ResourceNotFoundError): + file_path = self.file.name.split("/") + file_name = file_path.pop() + file_extension = file_name.split(".")[-1] + if upload_to: + upload_to = f"{upload_to}{uuid.uuid4()}.{file_extension}" + else: + upload_to = "/".join([*file_path, f"{uuid.uuid4()}.{file_extension}"]) + new_file = SimpleUploadedFile( + name=upload_to, + content=self.file.read(), + content_type=f"image/{file_extension}", + ) + image = deepcopy(self) + image.file = new_file + image.id = None + image._upload_to = lambda instance, filename: upload_to + return image + return None + + +class Image(BaseImage, HasOwner, ProjectRelated, OrganizationRelated): + name = models.CharField(max_length=255) owner = models.ForeignKey( "accounts.ProjectUser", on_delete=models.CASCADE, @@ -403,32 +437,9 @@ def get_related_project(self) -> Optional["Project"]: def duplicate( self, owner: Optional["ProjectUser"] = None, upload_to: str = "" ) -> Optional["Image"]: - with suppress(ResourceNotFoundError): - file_path = self.file.name.split("/") - file_name = file_path.pop() - file_extension = file_name.split(".")[-1] - if upload_to: - upload_to = f"{upload_to}{uuid.uuid4()}.{file_extension}" - else: - upload_to = "/".join([*file_path, f"{uuid.uuid4()}.{file_extension}"]) - new_file = SimpleUploadedFile( - name=upload_to, - 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 = super().duplicate(upload_to) + if image: + image.owner = (owner or self.owner,) image.save() return image return None @@ -491,3 +502,29 @@ def get_owner(self): def is_owned_by(self, user: "ProjectUser") -> bool: return user == self.get_owner() + + +class PeopleGroupImage(BaseImage, HasOwners): + people_group = models.ForeignKey( + "accounts.PeopleGroup", + on_delete=models.CASCADE, + null=False, + related_name="images", + ) + + def _upload_to(self, instance, filename): + return people_group_images_directory_path(instance, filename) + + def is_owned_by(self, user: "ProjectUser") -> bool: + """Whether the given user is the owners of the group.""" + people_group = self.people_group + members = people_group.managers() | people_group.leaders() + + return members.contains(user) + + def get_owners(self): + """Get the owners of the group.""" + people_group = self.people_group + members = people_group.managers() | people_group.leaders() + + return list(members) diff --git a/apps/files/serializers.py b/apps/files/serializers.py index d8747b6e..996e67d1 100644 --- a/apps/files/serializers.py +++ b/apps/files/serializers.py @@ -34,6 +34,7 @@ AttachmentType, Image, OrganizationAttachmentFile, + PeopleGroupImage, ProjectUserAttachmentFile, ProjectUserAttachmentLink, ) @@ -463,3 +464,9 @@ def validate_file(self, file): if file.size > limit: raise FileTooLargeError return file + + +class PeopleGroupImageSerializer(ImageSerializer): + class Meta(ImageSerializer.Meta): + model = PeopleGroupImage + fields = (*ImageSerializer.Meta.fields, "people_group") diff --git a/apps/files/urls.py b/apps/files/urls.py index fa3b591a..3f7cb917 100644 --- a/apps/files/urls.py +++ b/apps/files/urls.py @@ -1,6 +1,7 @@ from rest_framework.routers import DefaultRouter from apps.commons.urls import ( + organization_people_group_router_register, organization_router_register, project_router_register, user_router_register, @@ -9,6 +10,7 @@ AttachmentFileViewSet, AttachmentLinkViewSet, OrganizationAttachmentFileViewSet, + PeopleGroupGalleryViewSet, ProjectUserAttachmentFileViewSet, ProjectUserAttachmentLinkViewSet, ) @@ -44,3 +46,7 @@ ProjectUserAttachmentLinkViewSet, basename="ProjectUserAttachmentLink", ) + +organization_people_group_router_register( + router, r"gallery", PeopleGroupGalleryViewSet, basename="PeopleGroupGallery" +) diff --git a/apps/files/views.py b/apps/files/views.py index d0b39a9f..19937df3 100644 --- a/apps/files/views.py +++ b/apps/files/views.py @@ -17,11 +17,12 @@ from apps.accounts.permissions import HasBasePermission from apps.commons.permissions import IsOwner, ReadOnly, WillBeOwner from apps.commons.utils import map_action_to_permission -from apps.commons.views import MultipleIDViewsetMixin +from apps.commons.views import MultipleIDViewsetMixin, NestedPeopleGroupViewMixins from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission from apps.projects.models import Project from apps.projects.permissions import HasProjectPermission, ProjectIsNotLocked +from lib.views import NestedOrganizationViewMixins from .exceptions import ProtectedImageError from .models import ( @@ -37,6 +38,7 @@ AttachmentLinkSerializer, ImageSerializer, OrganizationAttachmentFileSerializer, + PeopleGroupImageSerializer, ProjectUserAttachmentFileSerializer, ProjectUserAttachmentLinkSerializer, ) @@ -276,3 +278,18 @@ def get_queryset(self) -> QuerySet: def create(self, request, *ar, **kw): request.data["owner"] = int(self.kwargs["user_id"]) return super().create(request, *ar, **kw) + + +class PeopleGroupGalleryViewSet( + NestedOrganizationViewMixins, NestedPeopleGroupViewMixins, viewsets.ModelViewSet +): + serializer_class = PeopleGroupImageSerializer + + def get_queryset(self): + modules_manager = self.people_group.get_related_module() + modules = modules_manager(self.people_group, self.request.user) + return modules.gallery() + + def create(self, request, *ar, **kw): + request.data["people_group"] = self.people_group.id + return super().create(request, *ar, **kw) 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..6d99fadf --- /dev/null +++ b/apps/modules/group.py @@ -0,0 +1,99 @@ +from functools import cached_property + +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 locations(self) -> QuerySet[Location]: + return Location.objects.filter(project__in=self.featured_projects()) + + def gallery(self): + return self.instance.images.all() + + @cached_property + def _is_structure(self): + try: + return self.instance.structure + # TODO + except Exception: + pass + + def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: + # structure = self._is_structure + # if not structure: + # return Document.objects.none() + + 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/projects/migrations/0002_alter_location_type.py b/apps/projects/migrations/0002_alter_location_type.py new file mode 100644 index 00000000..80b54438 --- /dev/null +++ b/apps/projects/migrations/0002_alter_location_type.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.10 on 2026-01-21 10:52 + +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, + ), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 6cf6ee28..97805ab4 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1,8 +1,9 @@ import logging import math import os +from copy import deepcopy from functools import reduce -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, Optional, Self import shortuuid as shortuuid from django.conf import settings @@ -119,9 +120,9 @@ class Project( project_query_string: str = "" organization_query_string: str = "organizations" - slugified_fields: List[str] = ["title"] + slugified_fields: list[str] = ["title"] slug_prefix: str = "project" - _auto_translated_fields: List[str] = ["title", "html:description", "purpose"] + _auto_translated_fields: list[str] = ["title", "html:description", "purpose"] class PublicationStatus(models.TextChoices): """Visibility setting of a project.""" @@ -351,7 +352,7 @@ def get_views(self) -> int: return self.get_cached_views().get("_total", 0) return self.mixpanel_events.count() - def get_views_organizations(self, organizations: List["Organization"]) -> int: + def get_views_organizations(self, organizations: list["Organization"]) -> int: """Return the project's views inside the given organization. If you plan on using this method multiple time, prefetch `organizations` @@ -371,7 +372,7 @@ def get_related_project(self) -> Optional["Project"]: """Return the project related to this model.""" return self - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" if self._related_organizations is None: self._related_organizations = list(self.organizations.all()) @@ -610,7 +611,7 @@ class ProjectScore(models.Model, ProjectRelated): def get_related_project(self) -> Project: return self.project - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: return self.project.get_related_organizations() def get_completeness(self) -> float: @@ -696,7 +697,7 @@ def get_related_project(self) -> Optional["Project"]: """Return the projects related to this model.""" return self.target - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.target.get_related_organizations() @@ -725,7 +726,7 @@ class BlogEntry( Date of the last change made to the blog entry. """ - _auto_translated_fields: List[str] = ["title", "html:content"] + _auto_translated_fields: list[str] = ["title", "html:content"] project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="blog_entries" @@ -758,7 +759,7 @@ def get_related_project(self) -> Optional["Project"]: """Return the projects related to this model.""" return self.project - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() @@ -811,7 +812,7 @@ class Goal( Status of the Goal. """ - _auto_translated_fields: List[str] = ["title", "html:description"] + _auto_translated_fields: list[str] = ["title", "html:description"] class GoalStatus(models.TextChoices): NONE = "na" @@ -843,7 +844,7 @@ def delete(self, using=None, keep_parents=False): if hasattr(project, "stat"): project.stat.update_goals() - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() @@ -861,9 +862,8 @@ def duplicate(self, project: "Project") -> "Goal": ) -class Location( +class AbstractLocation( HasAutoTranslatedFields, - ProjectRelated, DuplicableModel, models.Model, ): @@ -887,44 +887,62 @@ class Location( Type of the location (team or impact). """ - _auto_translated_fields: List[str] = ["title", "description"] + _auto_translated_fields: list[str] = ["title", "description"] class LocationType(models.TextChoices): """Type of a location.""" 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_organizations(self) -> list["Organization"]: + """Return the organizations related to this model.""" + return self.project.get_related_organizations() + + def duplicate(self) -> Self: + copy = deepcopy(self) + copy.pk = None + return copy + + +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 - 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, - ) + def duplicate(self, project: Project) -> "Location": + copy = super().duplicate() + copy.project = project + copy.save() + return copy class ProjectMessage( @@ -956,7 +974,7 @@ class ProjectMessage( Images used by the message. """ - _auto_translated_fields: List[str] = ["html:content"] + _auto_translated_fields: list[str] = ["html:content"] project = models.ForeignKey( "projects.Project", @@ -988,7 +1006,7 @@ def get_related_project(self) -> "Project": """Return the projects related to this model.""" return self.project - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() @@ -1028,7 +1046,7 @@ class ProjectTab( Description of the tab. """ - _auto_translated_fields: List[str] = ["title", "html:description"] + _auto_translated_fields: list[str] = ["title", "html:description"] class TabType(models.TextChoices): """Type of a tab.""" @@ -1051,7 +1069,7 @@ def get_related_project(self) -> Project: """Return the projects related to this model.""" return self.project - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.project.get_related_organizations() @@ -1076,7 +1094,7 @@ class ProjectTabItem( project_query_string: str = "tab__project" organization_query_string: str = "tab__project__organizations" - _auto_translated_fields: List[str] = ["title", "html:content"] + _auto_translated_fields: list[str] = ["title", "html:content"] tab = models.ForeignKey( "projects.ProjectTab", on_delete=models.CASCADE, related_name="items" @@ -1094,6 +1112,6 @@ def get_related_project(self) -> Project: """Return the projects related to this model.""" return self.tab.project - def get_related_organizations(self) -> List["Organization"]: + def get_related_organizations(self) -> list["Organization"]: """Return the organizations related to this model.""" return self.tab.project.get_related_organizations() diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index 8aa1032f..2ab65ff0 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional +from typing import Any from django.apps import apps from django.conf import settings @@ -18,6 +18,7 @@ ) from apps.commons.models import GroupData from apps.commons.serializers import ( + BaseLocationSerializer, OrganizationRelatedSerializer, ProjectRelatedSerializer, StringsImagesSerializer, @@ -38,7 +39,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 ( @@ -73,8 +74,8 @@ class BlogEntrySerializer( ProjectRelatedSerializer, serializers.ModelSerializer, ): - string_images_fields: List[str] = ["content"] - string_images_forbid_fields: List[str] = ["title"] + string_images_fields: list[str] = ["content"] + string_images_forbid_fields: list[str] = ["title"] string_images_upload_to: str = "blog_entry/images/" string_images_view: str = "BlogEntry-images-detail" string_images_process_template: bool = True @@ -115,13 +116,13 @@ def update(self, instance, validated_data): instance.refresh_from_db() return super(BlogEntrySerializer, self).update(instance, validated_data) - def get_related_organizations(self) -> List[Organization]: + 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"] @@ -129,7 +130,7 @@ def get_related_project(self) -> Optional[Project]: def get_string_images_kwargs( self, instance: BlogEntry, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get additional kwargs for image processing based on the instance.""" return {"project_id": instance.project.id} @@ -141,7 +142,7 @@ class GoalSerializer( ProjectRelatedSerializer, serializers.ModelSerializer, ): - string_images_forbid_fields: List[str] = ["title", "description"] + string_images_forbid_fields: list[str] = ["title", "description"] project_id = serializers.PrimaryKeyRelatedField( many=False, write_only=True, queryset=Project.objects.all(), source="project" @@ -158,13 +159,13 @@ class Meta: "project_id", ] - def get_related_organizations(self) -> List[Organization]: + 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"] @@ -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"] @@ -238,6 +230,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,9 +249,11 @@ class Meta: "is_followed", "is_featured", "is_group_project", + "updated_at", + "tags", ] - def get_is_followed(self, project: Project) -> Dict[str, Any]: + def get_is_followed(self, project: Project) -> dict[str, Any]: if "request" in self.context: user = self.context["request"].user if not user.is_anonymous: @@ -376,7 +371,7 @@ class ProjectAddTeamMembersSerializer(serializers.Serializer): def add_user( self, user: ProjectUser, project: Project, group: Group, role: str - ) -> Dict[str, Any]: + ) -> dict[str, Any]: created = not project.groups.filter(users=user).exists() if ( group.name == project.get_reviewers().name @@ -401,7 +396,7 @@ def add_user( def add_people_group( self, people_group: PeopleGroup, project: Project, group: Group, role: str - ) -> Dict[str, Any]: + ) -> dict[str, Any]: created = not project.groups.filter(people_groups=people_group).exists() people_group.groups.remove(*project.groups.filter(people_groups=people_group)) people_group.groups.add(group) @@ -456,15 +451,15 @@ class ProjectRemoveTeamMembersSerializer(serializers.Serializer): many=True, write_only=True, required=False, queryset=PeopleGroup.objects.all() ) - def validate_users(self, users: List[ProjectUser]) -> List[ProjectUser]: + def validate_users(self, users: list[ProjectUser]) -> list[ProjectUser]: project = get_object_or_404(Project, pk=self.initial_data["project"]) if all(owner in users for owner in project.get_owners().users.all()): raise RemoveLastProjectOwnerError return list(filter(lambda x: x.groups.filter(projects=project).exists(), users)) def validate_people_groups( - self, people_groups: List[PeopleGroup] - ) -> List[PeopleGroup]: + self, people_groups: list[PeopleGroup] + ) -> list[PeopleGroup]: project = get_object_or_404(Project, pk=self.initial_data["project"]) return list( filter(lambda x: x.groups.filter(projects=project).exists(), people_groups) @@ -501,8 +496,8 @@ class ProjectSerializer( OrganizationRelatedSerializer, serializers.ModelSerializer, ): - string_images_fields: List[str] = ["description"] - string_images_forbid_fields: List[str] = ["title", "purpose"] + string_images_fields: list[str] = ["description"] + string_images_forbid_fields: list[str] = ["title", "purpose"] string_images_upload_to: str = "project/images/" string_images_view: str = "Project-images-detail" string_images_process_template: bool = True @@ -611,19 +606,19 @@ class Meta: ] @staticmethod - def get_last_comment(project: Project) -> Optional[Dict]: + def get_last_comment(project: Project) -> dict | None: last_comment = ( project.comments.filter(reply_on=None).order_by("-created_at").first() ) return CommentSerializer(last_comment).data if last_comment else None - def get_linked_projects(self, project: Project) -> Dict[str, Any]: + def get_linked_projects(self, project: Project) -> dict[str, Any]: queryset = LinkedProject.objects.filter(target=project) user = getattr(self.context.get("request"), "user", AnonymousUser()) queryset = user.get_project_related_queryset(queryset) return LinkedProjectSerializer(queryset, many=True).data - def get_is_followed(self, project: Project) -> Dict[str, Any]: + def get_is_followed(self, project: Project) -> dict[str, Any]: if "request" in self.context: user = self.context["request"].user if not user.is_anonymous: @@ -635,10 +630,10 @@ def get_is_followed(self, project: Project) -> Dict[str, Any]: def get_string_images_kwargs( self, instance: Project, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: return {"project_id": instance.id} - def get_related_organizations(self) -> List[Organization]: + def get_related_organizations(self) -> list[Organization]: """Retrieve the related organizations""" if "organizations" in self.validated_data: return self.validated_data["organizations"] @@ -659,7 +654,7 @@ def update(self, instance, validated_data): ) return super(ProjectSerializer, self).update(instance, validated_data) - def validate_organizations_codes(self, value: List[Organization]): + def validate_organizations_codes(self, value: list[Organization]): if len(value) < 1: raise ProjectWithNoOrganizationError request = self.context.get("request") @@ -710,7 +705,7 @@ def validate_description(self, value: str): raise EmptyProjectDescriptionError return value - def validate_categories(self, value: List[ProjectCategory]): + def validate_categories(self, value: list[ProjectCategory]): organizations_codes = self.initial_data.get("organizations_codes", []) if self.instance and not organizations_codes: organizations_codes = self.instance.organizations.all().values_list( @@ -744,7 +739,7 @@ def get_project_id(version) -> str: return version.id @staticmethod - def get_delta(version) -> Dict[str, str]: + def get_delta(version) -> dict[str, str]: previous = version.prev_record while previous: previous_reason = previous.history_change_reason @@ -765,7 +760,7 @@ def get_delta(version) -> Dict[str, str]: return {} @staticmethod - def get_categories(version) -> List[str]: + def get_categories(version) -> list[str]: categories_ids = version.categories.all().values_list( "projectcategory_id", flat=True ) @@ -774,24 +769,24 @@ def get_categories(version) -> List[str]: ) @staticmethod - def get_tags(version) -> List[str]: + def get_tags(version) -> list[str]: tags_ids = version.tags.all().values_list("tag_id", flat=True) return Tag.objects.filter(id__in=tags_ids).values_list("title", flat=True) @staticmethod - def get_members(version) -> List[str]: + def get_members(version) -> list[str]: members = Project.objects.get(id=version.id).get_all_members() return [m.get_full_name() for m in members] @staticmethod - def get_comments(version) -> Dict[str, Any]: + def get_comments(version) -> dict[str, Any]: comments = Comment.history.as_of(version.history_date).filter( project__id=version.id, deleted_at=None ) return CommentSerializer(comments, many=True).data @staticmethod - def get_linked_projects(version) -> Dict[str, Any]: + def get_linked_projects(version) -> dict[str, Any]: linked_projects = LinkedProject.history.as_of(version.history_date).filter( target__id=version.id ) @@ -830,7 +825,7 @@ def get_project_id(version) -> str: return version.id @staticmethod - def get_updated_fields(version) -> List[str]: + def get_updated_fields(version) -> list[str]: previous = version.prev_record while previous: previous_reason = previous.history_change_reason @@ -854,7 +849,7 @@ class Meta: class ProjectMessageSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer ): - string_images_fields: List[str] = ["content"] + string_images_fields: list[str] = ["content"] string_images_upload_to: str = "project_messages/images/" string_images_view: str = "ProjectMessage-images-detail" @@ -897,7 +892,7 @@ def validate_reply_on(self, reply_on: ProjectMessage): def get_string_images_kwargs( self, instance: ProjectMessage, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get additional kwargs for image processing based on the instance.""" return {"project_id": instance.project.id} @@ -905,8 +900,8 @@ def get_string_images_kwargs( class ProjectTabSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer ): - string_images_fields: List[str] = ["description"] - string_images_forbid_fields: List[str] = ["title"] + string_images_fields: list[str] = ["description"] + string_images_forbid_fields: list[str] = ["title"] string_images_upload_to: str = "project_tabs/images/" string_images_view: str = "ProjectTab-images-detail" @@ -932,7 +927,7 @@ def validate_type(self, value: str): def get_string_images_kwargs( self, instance: ProjectTab, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get additional kwargs for image processing based on the instance.""" return {"project_id": instance.project.id} @@ -940,8 +935,8 @@ def get_string_images_kwargs( class ProjectTabItemSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer ): - string_images_fields: List[str] = ["content"] - string_images_forbid_fields: List[str] = ["title"] + string_images_fields: list[str] = ["content"] + string_images_forbid_fields: list[str] = ["title"] string_images_upload_to: str = "project_tab_items/images/" string_images_view: str = "ProjectTabItem-images-detail" @@ -964,7 +959,7 @@ class Meta: def get_string_images_kwargs( self, instance: ProjectTabItem, field_name: str, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get additional kwargs for image processing based on the instance.""" return { "project_id": instance.tab.project.id, 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) diff --git a/apps/search/filters.py b/apps/search/filters.py index 5728c89c..ddcae30e 100644 --- a/apps/search/filters.py +++ b/apps/search/filters.py @@ -1,5 +1,3 @@ -from typing import List, Optional - from django.db.models import BigIntegerField, Case, F, JSONField, Q, Value, When from django_filters import rest_framework as filters from rest_framework.filters import SearchFilter @@ -15,8 +13,8 @@ def MultiMatchSearchFieldsFilter( # noqa: N802 index: str, - fields: Optional[List[str]], - highlight: Optional[List[str]] = None, + fields: list[str] | None, + highlight: list[str] | None = None, highlight_size: int = 150, ): class _MultiMatchSearchFieldsFilter(SearchFilter): diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 2daf0109..0963381d 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-01-13 18:03+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:142 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..726ab799 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-01-13 18:03+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:142 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..bc643b3d 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-01-13 18:03+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:142 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..e1390992 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-01-13 18:03+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:142 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..f5924a85 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-01-13 18:03+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:142 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..a39d7ed2 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-01-13 18:03+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:142 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..5287acd7 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-01-13 18:03+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:142 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" diff --git a/services/crisalid/admin.py b/services/crisalid/admin.py index 7043fb35..a1f9d6b6 100644 --- a/services/crisalid/admin.py +++ b/services/crisalid/admin.py @@ -1,10 +1,14 @@ from contextlib import suppress +from typing import Any from django.contrib import admin, messages from django.db.models import Count +from django.db.models.query import QuerySet +from django.http.request import HttpRequest -from apps.accounts.models import ProjectUser +from apps.accounts.models import PeopleGroup, ProjectUser from apps.commons.admin import TranslateObjectAdminMixin +from services.crisalid.manager import CrisalidQuerySet from services.crisalid.tasks import vectorize_documents from .models import ( @@ -13,9 +17,21 @@ DocumentContributor, Identifier, Researcher, + Structure, ) +class IdentifierAminMixin: + @admin.display(description="identifiers count", ordering="identifiers_count") + def get_identifiers(self, instance): + # list all harvester name from this profile + result = [o.harvester for o in instance.identifiers.all()] + if not result: + return None + + return f"{', '.join(result)} ({len(result)})" + + @admin.register(Identifier) class IdentifierAdmin(admin.ModelAdmin): list_display = ("harvester", "value", "get_researcher", "get_documents") @@ -45,7 +61,7 @@ class DocumentContributorAdminInline(admin.StackedInline): @admin.register(Document) -class DocumentAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): +class DocumentAdmin(TranslateObjectAdminMixin, IdentifierAminMixin, admin.ModelAdmin): list_display = ( "title", "publication_date", @@ -89,22 +105,16 @@ def get_queryset(self, request): def get_contributors(self, instance): return instance.contributors.count() - @admin.display(description="identifiers count", ordering="identifiers_count") - def get_identifiers(self, instance): - # list all harvester name from this profile - result = [o.harvester for o in instance.identifiers.all()] - if not result: - return None - return f"{', '.join(result)} ({len(result)})" - @admin.register(Researcher) -class ResearcherAdmin(admin.ModelAdmin): +class ResearcherAdmin(IdentifierAminMixin, admin.ModelAdmin): list_display = ( "given_name", "family_name", "user", "get_documents", + "get_memberships", + "get_employments", "get_identifiers", ) search_fields = ( @@ -124,6 +134,8 @@ def get_queryset(self, request): .prefetch_related("identifiers", "documents") .annotate(identifiers_count=Count("identifiers__id")) .annotate(documents_count=Count("documents__id", distinct=True)) + .annotate(memberships_count=Count("memberships__id", distinct=True)) + .annotate(employments_count=Count("employments__id", distinct=True)) ) @admin.action(description="assign researcher on projects") @@ -138,17 +150,18 @@ def assign_user(self, request, queryset): continue for identifier in research.identifiers.all(): - if identifier.harvester != Identifier.Harvester.EPPN.value: + if identifier.harvester != Identifier.Harvester.LOCAL.value: continue user = None + email = identifier.value with suppress(ProjectUser.DoesNotExist): - user = ProjectUser.objects.get(email=identifier.value) + user = ProjectUser.objects.get(email=email) if not user: created += 1 user = ProjectUser( - email=identifier.value, + email=email, given_name=research.given_name, family_name=research.family_name, ) @@ -177,14 +190,70 @@ def assign_user(self, request, queryset): def get_documents(self, instance): return instance.documents_count - @admin.display(description="identifiers count", ordering="identifiers_count") - def get_identifiers(self, instance): - # list all harvester name from this profile - result = [o.harvester for o in instance.identifiers.all()] - if not result: - return None + @admin.display(description="number of memberships", ordering="-memberships_count") + def get_memberships(self, instance): + return instance.memberships_count - return f"{', '.join(result)} ({len(result)})" + @admin.display(description="number of employments", ordering="-employments_count") + def get_employments(self, instance): + return instance.employments_count + + +@admin.register(Structure) +class StructureAdmin(IdentifierAminMixin, admin.ModelAdmin): + list_display = ( + "acronym", + "name", + "organization", + "get_memberships", + "get_employments", + "get_identifiers", + ) + search_fields = ("acronym", "name", "organization__code") + autocomplete_fields = ("organization",) + actions = ("assign_group",) + + def get_queryset(self, request: HttpRequest) -> QuerySet[Any]: + return ( + super() + .get_queryset(request) + .select_related("organization") + .annotate( + memberships_count=Count("memberships__pk", distinct=True), + employments_count=Count("employments__pk", distinct=True), + ) + ) + + @admin.action(description="create/update groups") + def assign_group(self, request, queryset: CrisalidQuerySet): + for structure in queryset: + name = structure.name or structure.acronym + if not name: + continue + + parent = PeopleGroup.update_or_create_root(structure.organization) + group = PeopleGroup.objects.filter( + parent=parent, name=name, organization=structure.organization + ).first() + if not group: + group = PeopleGroup( + name=name, parent=parent, organization=structure.organization + ) + + group.save() + member_group = group.get_members() + for membership in structure.memberships.select_related("user").filter( + user__isnull=False + ): + membership.user.groups.add(member_group) + + @admin.display(description="number of memberships", ordering="-memberships_count") + def get_memberships(self, instance): + return instance.memberships_count + + @admin.display(description="number of employments", ordering="-employments_count") + def get_employments(self, instance): + return instance.employments_count @admin.register(CrisalidConfig) diff --git a/services/crisalid/factories.py b/services/crisalid/factories.py index 44435c20..bba115b8 100644 --- a/services/crisalid/factories.py +++ b/services/crisalid/factories.py @@ -36,6 +36,8 @@ def value(self): Identifier.Harvester.EPPN: faker.unique.email(), Identifier.Harvester.DOI: faker.unique.doi(), Identifier.Harvester.PMID: faker.unique.url(), + Identifier.Harvester.NNS: faker.unique.uuid4(), + Identifier.Harvester.RNSR: faker.unique.uuid4(), }[self.harvester] diff --git a/services/crisalid/management/commands/populate_crisalid.py b/services/crisalid/management/commands/populate_crisalid.py index 8350182d..8770fdb6 100644 --- a/services/crisalid/management/commands/populate_crisalid.py +++ b/services/crisalid/management/commands/populate_crisalid.py @@ -10,7 +10,11 @@ Identifier, Researcher, ) -from services.crisalid.populates import PopulateDocument, PopulateResearcher +from services.crisalid.populates import ( + PopulateDocument, + PopulateResearcher, + PopulateStructure, +) from services.crisalid.populates.base import AbstractPopulate from services.crisalid.utils.time import timeit from services.mistral.models import DocumentEmbedding @@ -23,13 +27,13 @@ def add_arguments(self, parser): parser.add_argument( "organization", choices=CrisalidConfig.objects.filter( - organization__code__isnull=False + organization__code__isnull=False, active=True ).values_list("organization__code", flat=True), help="organization code", ) parser.add_argument( "command", - choices=("document", "researcher", "all"), + choices=("document", "researcher", "structure", "all"), help="elements to populate", ) parser.add_argument( @@ -111,3 +115,12 @@ def handle(self, **options): where={"external_EQ": False}, **options, ) + + if command in ("all", "structure"): + populate = PopulateStructure(config) + self.populate_crisalid( + service, + populate, + query="organisations", + **options, + ) diff --git a/services/crisalid/migrations/0003_alter_identifier_harvester_structure_and_more.py b/services/crisalid/migrations/0003_alter_identifier_harvester_structure_and_more.py new file mode 100644 index 00000000..7ac0cee2 --- /dev/null +++ b/services/crisalid/migrations/0003_alter_identifier_harvester_structure_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2.25 on 2025-12-09 09:41 + +import apps.commons.mixins +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0003_initial"), + ("crisalid", "0002_crisalidconfig_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="identifier", + name="harvester", + field=models.CharField( + choices=[ + ("hal", "Hal"), + ("scanr", "Scanr"), + ("openalex", "Openalex"), + ("idref", "Idref"), + ("scopus", "Scopus"), + ("orcid", "Orcid"), + ("local", "Local"), + ("eppn", "Eppn"), + ("doi", "Doi"), + ("pmid", "Pmid"), + ("nns", "Nns"), + ("rnsr", "Rnsr"), + ], + max_length=50, + ), + ), + migrations.CreateModel( + name="Structure", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("updated", models.DateTimeField(auto_created=True, auto_now=True)), + ("acronym", models.TextField(blank=True, null=True)), + ("name", models.TextField()), + ( + "identifiers", + models.ManyToManyField( + related_name="structures", to="crisalid.identifier" + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="structures", + to="organizations.organization", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(apps.commons.mixins.OrganizationRelated, models.Model), + ), + migrations.AddField( + model_name="researcher", + name="employments", + field=models.ManyToManyField( + related_name="employments", to="crisalid.structure" + ), + ), + migrations.AddField( + model_name="researcher", + name="memberships", + field=models.ManyToManyField( + related_name="memberships", to="crisalid.structure" + ), + ), + ] diff --git a/services/crisalid/migrations/0004_structure_group.py b/services/crisalid/migrations/0004_structure_group.py new file mode 100644 index 00000000..8be7d9c4 --- /dev/null +++ b/services/crisalid/migrations/0004_structure_group.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.10 on 2026-01-21 06:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0003_peoplegroup_tags"), + ("crisalid", "0003_alter_identifier_harvester_structure_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="structure", + name="group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="structure", + to="accounts.peoplegroup", + ), + ), + ] diff --git a/services/crisalid/models.py b/services/crisalid/models.py index d60baed5..f3e9b8e9 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 @@ -58,6 +57,8 @@ class Harvester(models.TextChoices): EPPN = "eppn" DOI = "doi" PMID = "pmid" + NNS = "nns" + RNSR = "rnsr" harvester = models.CharField(max_length=50, choices=Harvester.choices) value = models.CharField(max_length=255) @@ -94,6 +95,12 @@ class Researcher(CrisalidDataModel): ) objects = CrisalidQuerySet.as_manager() + memberships = models.ManyToManyField( + "crisalid.Structure", related_name="memberships" + ) + employments = models.ManyToManyField( + "crisalid.Structure", related_name="employments" + ) def __str__(self): if hasattr(self, "user") and self.user is not None: @@ -121,7 +128,9 @@ class Meta: ] -class Document(OrganizationRelated, HasAutoTranslatedFields, CrisalidDataModel): +class Document( + HasEmbending, OrganizationRelated, HasAutoTranslatedFields, CrisalidDataModel +): """ Represents a research publicaiton (or 'document') in the Crisalid system. """ @@ -199,6 +208,10 @@ class DocumentType(models.TextChoices): organization_query_string = "contributors__user__groups__organizations" + class Meta: + # order by publicattion date, and put "null date" at last + ordering = (models.F("publication_date").desc(nulls_last=True),) + def get_related_organizations(self): """organizations from user""" return list( @@ -217,24 +230,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 @@ -285,6 +280,29 @@ def values(cls) -> Generator[tuple[str]]: yield v +class Structure(OrganizationRelated, CrisalidDataModel): + acronym = models.TextField(null=True, blank=True) + name = models.TextField() + identifiers = models.ManyToManyField( + "crisalid.Identifier", related_name="structures" + ) + organization = models.ForeignKey( + "organizations.Organization", + on_delete=models.CASCADE, + related_name="structures", + ) + objects = CrisalidQuerySet.as_manager() + group = models.ForeignKey( + "accounts.PeopleGroup", + on_delete=models.SET_NULL, + null=True, + related_name="structure", + ) + + def __str__(self): + return self.name + + class CrisalidConfig(OrganizationRelated, models.Model): """model for crisalid config with host/pass for connected to crisalid, is linked to a one organization diff --git a/services/crisalid/populates/__init__.py b/services/crisalid/populates/__init__.py index b8986cd3..8302bbf8 100644 --- a/services/crisalid/populates/__init__.py +++ b/services/crisalid/populates/__init__.py @@ -1,9 +1,13 @@ from .caches import LiveCache from .document import PopulateDocument +from .identifier import PopulateIdentifier from .researcher import PopulateResearcher +from .structure import PopulateStructure __all__ = ( "PopulateResearcher", "PopulateDocument", + "PopulateStructure", + "PopulateIdentifier", "LiveCache", ) diff --git a/services/crisalid/populates/base.py b/services/crisalid/populates/base.py index 5c597589..cc92ba42 100644 --- a/services/crisalid/populates/base.py +++ b/services/crisalid/populates/base.py @@ -19,6 +19,12 @@ def __init__(self, config: CrisalidConfig, cache: TCACHE = None): self.config = config self.cache = cache or LiveCache() + def sanitize_string(self, value) -> str: + """strip value and convert it to string""" + if not value: + return "" + return str(value).strip() + def sanitize_languages(self, values: list[dict[str, str]]) -> str: """convert languages choices from crisalid fields crisalid return a list of objects with "language" and "value" assosiated from the language @@ -29,7 +35,7 @@ def sanitize_languages(self, values: list[dict[str, str]]) -> str: maps_languages = {} for value in values: - maps_languages[value["language"]] = (value["value"] or "").strip() + maps_languages[value["language"]] = self.sanitize_string(value["value"]) return ( maps_languages.get("en") diff --git a/services/crisalid/populates/document.py b/services/crisalid/populates/document.py index ab5025b4..0069ddef 100644 --- a/services/crisalid/populates/document.py +++ b/services/crisalid/populates/document.py @@ -1,10 +1,6 @@ from services.crisalid import relators -from services.crisalid.models import ( - CrisalidConfig, - Document, - DocumentContributor, - Identifier, -) +from services.crisalid.models import CrisalidConfig, Document, DocumentContributor +from services.crisalid.populates.identifier import PopulateIdentifier from .base import AbstractPopulate from .logger import logger @@ -14,7 +10,10 @@ class PopulateDocument(AbstractPopulate): def __init__(self, config: CrisalidConfig, cache=None): super().__init__(config, cache) - self.populate_researcher = PopulateResearcher(self.config, self.cache) + self.populate_identifiers = PopulateIdentifier(self.config, self.cache) + self.populate_researcher = PopulateResearcher( + self.config, self.cache, populate_identifiers=self.populate_identifiers + ) def sanitize_document_type(self, data: str | None): """Check documentType , and return unknow value if is not set in enum""" @@ -37,15 +36,7 @@ def sanitize_roles(self, data: list[str]) -> list[str]: def single(self, data: dict) -> Document | None: """this method create/update only on document from crisalid""" # identifiers (hal, openalex, idref ...ect) - documents_identifiers = [] - for recorded in data["recorded_by"]: - identifier = self.cache.model( - Identifier, - value=recorded["uid"], - harvester=recorded["harvester"].lower(), - ) - self.cache.save(identifier) - documents_identifiers.append(identifier) + documents_identifiers = self.populate_identifiers.multiple(data["recorded_by"]) # no identifiers for this documents, we ignore it if not documents_identifiers: diff --git a/services/crisalid/populates/identifier.py b/services/crisalid/populates/identifier.py new file mode 100644 index 00000000..e18dbe2a --- /dev/null +++ b/services/crisalid/populates/identifier.py @@ -0,0 +1,39 @@ +from services.crisalid.models import Identifier + +from .base import AbstractPopulate + + +class PopulateIdentifier(AbstractPopulate): + """Populate class for identifiers element + + ex: + { + "type": "RNSR", + "value": "200612823S" + } + """ + + def sanitize_harvester(self, harvester: str) -> str: + # harvester can be "orcid_id" or "orcid" + if harvester == "orcid_id": + return Identifier.Harvester.ORCID + + if harvester not in Identifier.Harvester: + return None + + return harvester + + def single(self, data: dict) -> Identifier | None: + harvester = self.sanitize_harvester(self.sanitize_string(data["type"]).lower()) + value = self.sanitize_string(data["value"]) + + if not all((harvester, value)): + return None + + identifier = self.cache.model( + Identifier, + value=value, + harvester=harvester, + ) + self.cache.save(identifier) + return identifier diff --git a/services/crisalid/populates/researcher.py b/services/crisalid/populates/researcher.py index a6245a80..1f90f5a4 100644 --- a/services/crisalid/populates/researcher.py +++ b/services/crisalid/populates/researcher.py @@ -1,10 +1,21 @@ from apps.accounts.models import PrivacySettings, ProjectUser from services.crisalid.models import Identifier, Researcher +from services.crisalid.populates.identifier import PopulateIdentifier +from services.crisalid.populates.structure import PopulateStructure from .base import AbstractPopulate class PopulateResearcher(AbstractPopulate): + def __init__(self, *ar, populate_identifiers=None, populate_structures=None, **kw): + super().__init__(*ar, **kw) + self.populate_identifiers = populate_identifiers or PopulateIdentifier( + self.config, self.cache + ) + self.populate_structures = populate_structures or PopulateStructure( + self.config, self.cache, populate_identifiers=self.populate_identifiers + ) + def get_names(self, data): given_name = family_name = "" @@ -45,29 +56,26 @@ def update_user(self, user: ProjectUser) -> ProjectUser: return user def check_mapping_user( - self, researcher: Researcher, data: dict + self, + researcher: Researcher, + identifiers: list[Identifier], + given_name: str, + family_name: str, ) -> ProjectUser | None: """match user from researcher (need eppn)""" if researcher.user: return self.update_user(researcher.user) - for iden in data["identifiers"]: - if iden["type"].lower() != Identifier.Harvester.EPPN.value: + for iden in identifiers: + if iden.harvester != Identifier.Harvester.EPPN: continue - given_name, family_name = self.get_names(data) - return self.create_user(iden["value"], given_name, family_name) + return self.create_user(iden.value, given_name, family_name) return None def single(self, data: dict) -> Researcher | None: - researcher_identifiers = [] - for iden in data["identifiers"]: - identifier = self.cache.model( - Identifier, value=iden["value"], harvester=iden["type"].lower() - ) - self.cache.save(identifier) - researcher_identifiers.append(identifier) + researcher_identifiers = self.populate_identifiers.multiple(data["identifiers"]) # researcher withtout any identifiers no neeeeeeed to be created if not researcher_identifiers: @@ -85,11 +93,24 @@ def single(self, data: dict) -> Researcher | None: ) given_name, family_name = self.get_names(data) - user = self.check_mapping_user(researcher, data) + user = self.check_mapping_user( + researcher, researcher_identifiers, given_name, family_name + ) self.cache.save( researcher, given_name=given_name, family_name=family_name, user=user ) - self.cache.save_m2m(researcher, identifiers=researcher_identifiers) + + m2m = {"identifiers": researcher_identifiers} + + memberships = data.get("memberships") + if memberships: + m2m["memberships"] = self.populate_structures.multiple(memberships) + + employments = data.get("employments") + if employments: + m2m["employments"] = self.populate_structures.multiple(employments) + + self.cache.save_m2m(researcher, **m2m) return researcher diff --git a/services/crisalid/populates/structure.py b/services/crisalid/populates/structure.py new file mode 100644 index 00000000..8681535c --- /dev/null +++ b/services/crisalid/populates/structure.py @@ -0,0 +1,57 @@ +from services.crisalid.models import Structure +from services.crisalid.populates.identifier import PopulateIdentifier + +from .base import AbstractPopulate + + +class PopulateStructure(AbstractPopulate): + """Populate class for structure element + + ex: + { + "acronym": "CES", + "types": [ + "Organisation", + "ResearchStructure" + ], + "names": [ + { + "language": "fr", + "value": "UMR 8174 - CES" + } + ], + "identifiers": [ + { + "type": "RNSR", + "value": "200612823S" + }, + { + "type": "local", + "value": "U02C" + } + ] + } + """ + + def __init__(self, *ar, populate_identifiers=None, **kw): + super().__init__(*ar, **kw) + self.populate_identifiers = populate_identifiers or PopulateIdentifier( + self.config, self.cache + ) + + def single(self, data: dict) -> Structure | None: + acronym = self.sanitize_string(data["acronym"]) + name = self.sanitize_languages(data["names"]) + identifiers = self.populate_identifiers.multiple(data["identifiers"]) + + # no create structure if no identifiers are set + if not identifiers: + return None + + structure = self.cache.from_identifiers(Structure, identifiers) + self.cache.save( + structure, acronym=acronym, name=name, organization=self.config.organization + ) + self.cache.save_m2m(structure, identifiers=identifiers) + + return structure diff --git a/services/crisalid/queries/documents.graphql b/services/crisalid/queries/documents.graphql index 8d587082..e4a936fd 100644 --- a/services/crisalid/queries/documents.graphql +++ b/services/crisalid/queries/documents.graphql @@ -1,6 +1,5 @@ query PopulateFromCrisalid($limit: Int, $offset: Int, $where: DocumentWhere) { documents(limit: $limit, offset: $offset, where: $where) { - uid, publication_date, document_type, @@ -17,7 +16,6 @@ query PopulateFromCrisalid($limit: Int, $offset: Int, $where: DocumentWhere) { has_contributions { roles, contributor { - uid display_name, names { first_names { @@ -30,15 +28,37 @@ query PopulateFromCrisalid($limit: Int, $offset: Int, $where: DocumentWhere) { } } identifiers { - type + harvester: type value } + employments { + acronym + names { + language + value + } + identifiers { + harvester: type + value + } + } + memberships { + acronym + names { + language + value + } + identifiers { + harvester: type + value + } + } } } recorded_by { harvester - uid, + value: uid, } } } \ No newline at end of file diff --git a/services/crisalid/queries/organisations.graphql b/services/crisalid/queries/organisations.graphql new file mode 100644 index 00000000..85d2fc54 --- /dev/null +++ b/services/crisalid/queries/organisations.graphql @@ -0,0 +1,14 @@ +# this query for organisations ( structure / labo ) +query PopulateFromCrisalid($limit: Int, $offset: Int, $where: OrganisationWhere) { + organisations(limit: $limit, offset: $offset, where: $where) { + acronym + names { + language + value + } + identifiers { + harvester: type + value + } + } +} diff --git a/services/crisalid/queries/people.graphql b/services/crisalid/queries/people.graphql index 2f17f160..ff304917 100644 --- a/services/crisalid/queries/people.graphql +++ b/services/crisalid/queries/people.graphql @@ -1,6 +1,5 @@ query PopulateFromCrisalid($limit: Int, $offset: Int, $where: PersonWhere) { people(limit: $limit, offset: $offset, where: $where) { - uid display_name names { first_names { @@ -13,8 +12,32 @@ query PopulateFromCrisalid($limit: Int, $offset: Int, $where: PersonWhere) { } } identifiers { - type + harvester: type value } + + employments { + acronym + names { + language + value + } + identifiers { + harvester: type + value + } + } + + memberships { + acronym + names { + language + value + } + identifiers { + harvester: type + value + } + } } } diff --git a/services/crisalid/serializers.py b/services/crisalid/serializers.py index 5fe5ba1b..ff555fea 100644 --- a/services/crisalid/serializers.py +++ b/services/crisalid/serializers.py @@ -40,7 +40,12 @@ class ResearcherSerializer(serializers.ModelSerializer): class Meta: model = Researcher - exclude = ("updated",) + fields = ( + "id", + "user", + "identifiers", + "display_name", + ) def get_display_name(self, instance): return str(instance) @@ -53,9 +58,9 @@ class ResearcherDocumentsSerializer(ResearcherSerializer): class Meta: model = Researcher - read_only_fields = ("display_name",) fields = ( "identifiers", + "display_name", "user", "id", ) diff --git a/services/crisalid/tasks.py b/services/crisalid/tasks.py index b3d7226a..8bc2045e 100644 --- a/services/crisalid/tasks.py +++ b/services/crisalid/tasks.py @@ -4,8 +4,15 @@ from services.crisalid.bus.constant import CrisalidEventEnum, CrisalidTypeEnum from services.crisalid.bus.consumer import on_event from services.crisalid.interface import CrisalidService -from services.crisalid.models import CrisalidConfig, Document, Identifier, Researcher +from services.crisalid.models import ( + CrisalidConfig, + Document, + Identifier, + Researcher, + Structure, +) from services.crisalid.populates import PopulateDocument, PopulateResearcher +from services.crisalid.populates.structure import PopulateStructure logger = logging.getLogger(__name__) @@ -16,6 +23,8 @@ def get_crisalid_config(crisalid_config_id: int) -> CrisalidConfig: ) +# TODO(remi): convert fields to graphql request + # https://github.com/CRISalid-esr/crisalid-ikg/blob/dev-main/app/amqp/amqp_person_event_message_factory.py#L28 # https://github.com/CRISalid-esr/crisalid-ikg/blob/dev-main/app/amqp/amqp_document_event_message_factory.py#L37 @@ -27,8 +36,18 @@ def create_researcher(crisalid_config_id: int, fields: dict): config = get_crisalid_config(crisalid_config_id) logger.error("receive %s for organization %s", fields, config.organization) + service = CrisalidService(config) + + # fetch data from apollo + data = service.query("people", offset=0, limit=1, where={"uid_EQ": fields["uid"]})[ + "people" + ] + if not data: + logger.warning("no result fetching crisalid_uid=%s", fields["uid"]) + return + populate = PopulateResearcher(config) - populate.single(fields) + populate.single(data[0]) @on_event(CrisalidTypeEnum.PERSON, CrisalidEventEnum.DELETED) @@ -51,6 +70,54 @@ def delete_researcher(crisalid_config_id: int, fields: dict): logger.info("deleted = %s", deleted) +# ---- +# Documents task (publications/conference ....) +# ---- +@on_event(CrisalidTypeEnum.STRUCTURE, CrisalidEventEnum.CREATED) +@on_event(CrisalidTypeEnum.STRUCTURE, CrisalidEventEnum.UPDATED) +@app.task(name=f"{__name__}.create_structure") +def create_structure(crisalid_config_id: int, fields: dict): + config = get_crisalid_config(crisalid_config_id) + logger.error("receive %s for organization %s", fields, config.organization) + + service = CrisalidService(config) + + # fetch data from apollo + data = service.query( + "organisations", offset=0, limit=1, where={"uid_EQ": fields["uid"]} + )["organisations"] + if not data: + logger.warning("no result fetching crisalid_uid=%s", fields["uid"]) + return + + populate = PopulateStructure(config) + populate.single(data[0]) + + +@on_event(CrisalidTypeEnum.STRUCTURE, CrisalidEventEnum.DELETED) +@app.task(name=f"{__name__}.delete_structure") +def delete_structure(crisalid_config_id: int, fields: dict): + config = get_crisalid_config(crisalid_config_id) + logger.error("receive %s for organization %s", fields, config.organization) + + identifiers = [ + {"harvester": iden["type"].lower(), "value": iden["value"]} + for iden in fields["identifiers"] + if iden["type"].lower() + not in (Identifier.Harvester.LOCAL, Identifier.Harvester.EPPN) + ] + + qs = Structure.objects.from_identifiers(identifiers, distinct=False).filter( + organization=config.organization + ) + deleted, _ = qs.delete() + + logger.info("deleted = %s", deleted) + + +# ---- +# Documents task (publications/conference ....) +# ---- @on_event(CrisalidTypeEnum.DOCUMENT, CrisalidEventEnum.CREATED) @on_event(CrisalidTypeEnum.DOCUMENT, CrisalidEventEnum.UPDATED) @app.task(name=f"{__name__}.create_document") @@ -88,6 +155,9 @@ def delete_document(crisalid_config_id: int, fields: dict): logger.info("deleted = %s", deleted) +# ---- +# Vectorize documents for similarity +# ---- @app.task(name="Vectorize documents") def vectorize_documents(documents_pks: list[int]): for obj in Document.objects.filter(pk__in=documents_pks): diff --git a/services/crisalid/tests/fixtures/structures.graphql.json b/services/crisalid/tests/fixtures/structures.graphql.json new file mode 100644 index 00000000..ce40495a --- /dev/null +++ b/services/crisalid/tests/fixtures/structures.graphql.json @@ -0,0 +1,15 @@ +{ + "acronym": "LabEx CAP", + "names": [ + { + "language": "fr", + "value": "CAP" + } + ], + "identifiers": [ + { + "harvester": "local", + "value": "DGI01" + } + ] +} \ No newline at end of file diff --git a/services/crisalid/tests/test_populate.py b/services/crisalid/tests/test_populate.py index 408b3853..bcbc7ba8 100644 --- a/services/crisalid/tests/test_populate.py +++ b/services/crisalid/tests/test_populate.py @@ -5,8 +5,9 @@ from apps.accounts.factories import UserFactory from apps.accounts.models import PrivacySettings, ProjectUser from services.crisalid.factories import CrisalidConfigFactory -from services.crisalid.models import Document, Identifier, Researcher +from services.crisalid.models import Document, Identifier, Researcher, Structure from services.crisalid.populates import PopulateDocument, PopulateResearcher +from services.crisalid.populates.structure import PopulateStructure class TestPopulateResearcher(test.TestCase): @@ -226,7 +227,10 @@ def test_create_publication(self): } ], "identifiers": [ - {"type": "eppn", "value": "marty.mcfly@non-de-zeus.fr"}, + { + "type": "eppn", + "value": "marty.mcfly@non-de-zeus.fr", + }, {"type": "idref", "value": "4545454545454"}, {"type": "local", "value": "v55555"}, ], @@ -236,9 +240,8 @@ def test_create_publication(self): ], "recorded_by": [ { - "uid": "hals-truc", - "harvester": Identifier.Harvester.HAL.value, - "value": "", + "type": Identifier.Harvester.HAL.value, + "value": "hals-truc", } ], } @@ -359,3 +362,46 @@ def test_sanitize_document_type(self): ), Document.DocumentType.AUDIOVISUAL_DOCUMENT.value, ) + + +class TestPopulateStructure(test.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.config = CrisalidConfigFactory() + cls.popu = PopulateStructure(cls.config) + + def test_create_structure(self): + data = { + "acronym": "LabEx CAP", + "names": [{"language": "fr", "value": "CAP"}], + "identifiers": [{"type": "local", "value": "DGI01"}], + } + + new_obj = self.popu.single(data) + + # check obj from db + obj = Structure.objects.first() + self.assertEqual(obj, new_obj) + + self.assertEqual(obj.acronym, "LabEx CAP") + self.assertEqual(obj.name, "CAP") + self.assertEqual(obj.organization, self.config.organization) + self.assertEqual(obj.identifiers.count(), 1) + iden = obj.identifiers.first() + self.assertEqual(iden.value, "DGI01") + self.assertEqual(iden.harvester, "local") + + def test_create_structure_whitout_identifiers(self): + data = { + "acronym": "LabEx CAP", + "names": [{"language": "fr", "value": "CAP"}], + "identifiers": [], + } + + new_obj = self.popu.single(data) + + # check obj from db + obj = Structure.objects.first() + self.assertIsNone(obj) + self.assertIsNone(new_obj) diff --git a/services/crisalid/tests/test_tasks.py b/services/crisalid/tests/test_tasks.py index a0faf6e3..c30dbb2b 100644 --- a/services/crisalid/tests/test_tasks.py +++ b/services/crisalid/tests/test_tasks.py @@ -95,10 +95,11 @@ def test_delete_research(self): self.assertTrue(Researcher.objects.filter(pk=researcher.pk).exists()) - def test_create_researcher(self): + @patch("services.crisalid.interface.Client") + def test_create_researcher(self, client_gql): # other check/tests in test_views.py - fields = { - "uid": "05-11-1995-uuid", + fields = {"uid": "05-11-1995-uuid"} + data = { "names": [ { "first_names": [{"value": "marty", "language": "fr"}], @@ -110,6 +111,8 @@ def test_create_researcher(self): ], } + client_gql().execute.return_value = {"people": [data]} + create_researcher(self.config.pk, fields) # check obj from db @@ -171,9 +174,8 @@ def test_create_document(self, client_gql): ], "recorded_by": [ { - "uid": "hals-truc", - "harvester": Identifier.Harvester.HAL.value, - "value": "", + "type": Identifier.Harvester.HAL.value, + "value": "hals-truc", } ], } 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 95debbed..63a60c5c 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, @@ -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( { @@ -200,11 +207,77 @@ def analytics(self, request, *args, **kwargs): ) -class PublicationViewSet(AbstractDocumentViewSet): +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 +): + 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 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 diff --git a/services/mistral/migrations/0005_groupembedding.py b/services/mistral/migrations/0005_groupembedding.py new file mode 100644 index 00000000..305a655a --- /dev/null +++ b/services/mistral/migrations/0005_groupembedding.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.10 on 2026-01-21 08:13 + +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, + }, + ), + ] 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)