diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index d6d913e2..5bde5f99 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -16,7 +16,7 @@ from services.keycloak.interface import KeycloakService from .exports import UserResource -from .models import PeopleGroup, ProjectUser +from .models import PeopleGroup, PeopleGroupLocation, ProjectUser from .utils import get_group_permissions @@ -169,6 +169,16 @@ class PeopleGroupAdmin(TranslateObjectAdminMixin, admin.ModelAdmin): list_filter = ("organization",) +@admin.register(PeopleGroupLocation) +class PeopleGroupLocationAdmin(admin.ModelAdmin): + list_display = ("title", "description", "type", "people_group") + list_display_links = ( + list_display[0], + "people_group", + ) + search_fields = ("title", "description", "type", "people_group__title") + + @admin.register(Permission) class PermissionAdmin(admin.ModelAdmin): list_display = ("name", "codename", "content_type") diff --git a/apps/accounts/factories.py b/apps/accounts/factories.py index 61440c4a..904d7501 100644 --- a/apps/accounts/factories.py +++ b/apps/accounts/factories.py @@ -9,7 +9,13 @@ from services.keycloak.factories import KeycloakAccountFactory from services.keycloak.interface import KeycloakService -from .models import PeopleGroup, PrivacySettings, ProjectUser, UserScore +from .models import ( + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, + UserScore, +) faker = Faker() @@ -121,3 +127,14 @@ def create(cls, **kwargs): def with_leader(self, create, extracted, **kwargs): if create and extracted is True: UserFactory(groups=[self.get_leaders()]) + + +class PeopleGroupLocationFactory(factory.django.DjangoModelFactory): + title = factory.Faker("sentence") + description = factory.Faker("text") + lat = factory.Faker("latitude") + lng = factory.Faker("longitude") + type = PeopleGroupLocation.LocationType.ADDRESS.value + + class Meta: + model = PeopleGroupLocation diff --git a/apps/accounts/migrations/0004_peoplegrouplocation.py b/apps/accounts/migrations/0004_peoplegrouplocation.py new file mode 100644 index 00000000..a749043f --- /dev/null +++ b/apps/accounts/migrations/0004_peoplegrouplocation.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.11 on 2026-02-20 11:31 + +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)), + ( + "people_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="locations", + to="accounts.peoplegroup", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + apps.commons.mixins.OrganizationRelated, + services.translator.mixins.HasAutoTranslatedFields, + apps.commons.mixins.DuplicableModel, + models.Model, + ), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index e8444f29..f0197a07 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -35,13 +35,26 @@ 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(OrganizationRelated, AbstractLocation): + """base location for group""" + + people_group = models.ForeignKey( + "accounts.PeopleGroup", + on_delete=models.CASCADE, + related_name="locations", + ) + + def get_related_organizations(self) -> list["Organization"]: + return [self.people_group.organization] + + class PeopleGroup( HasEmbedding, HasRelatedModules, diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 290f4722..dbbed341 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,7 +16,10 @@ ) from apps.commons.mixins import HasPermissionsSetup from apps.commons.models import GroupData -from apps.commons.serializers import StringsImagesSerializer +from apps.commons.serializers import ( + BaseLocationSerializer, + StringsImagesSerializer, +) from apps.files.models import Image from apps.files.serializers import ImageSerializer from apps.modules.serializers import ModulesSerializers @@ -38,7 +41,13 @@ UserRoleAssignmentError, UserRolePermissionDeniedError, ) -from .models import AnonymousUser, PeopleGroup, PrivacySettings, ProjectUser +from .models import ( + AnonymousUser, + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, +) from .utils import get_default_group, get_instance_from_group @@ -122,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 ( @@ -191,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 @@ -211,16 +220,16 @@ def get_people_groups(self, user: ProjectUser) -> list: queryset, many=True, context=self.context ).data - def get_skills(self, user: ProjectUser) -> List[Dict]: + def get_skills(self, user: ProjectUser) -> list[dict]: return SkillLightSerializer(user.skills.all(), many=True).data - def get_needs_mentor_on(self, user: ProjectUser) -> List[Dict]: + def get_needs_mentor_on(self, user: ProjectUser) -> list[dict]: if getattr(user, "needs_mentor_on", None): skills = Skill.objects.filter(id__in=user.needs_mentor_on) return SkillLightSerializer(skills, many=True).data return [] - def get_can_mentor_on(self, user: ProjectUser) -> List[Dict]: + def get_can_mentor_on(self, user: ProjectUser) -> list[dict]: if getattr(user, "can_mentor_on", None): skills = Skill.objects.filter(id__in=user.can_mentor_on) return SkillLightSerializer(skills, many=True).data @@ -238,6 +247,37 @@ class Meta: fields = read_only_fields +class PeopleGroupLocationSerializer(BaseLocationSerializer): + people_group = serializers.PrimaryKeyRelatedField( + required=False, queryset=PeopleGroup.objects.all() + ) + + class Meta(BaseLocationSerializer.Meta): + model = PeopleGroupLocation + fields = (*BaseLocationSerializer.Meta.fields, "people_group") + + +class PeopleGroupLocationSuperLightSerializer(PeopleGroupLocationSerializer): + people_group = PeopleGroupSuperLightSerializer(read_only=True) + + +class PeopleGroupAddLocationsSerializer(serializers.Serializer): + people_group = HiddenPrimaryKeyRelatedField( + required=False, write_only=True, queryset=PeopleGroup.objects.all() + ) + locations = PeopleGroupLocationSerializer(many=True) + + def create(self, validated_data): + people_group = validated_data["people_group"] + locations = validated_data["locations"] + + locations_objs = [ + PeopleGroupLocation(**datas, people_group=people_group) + for datas in locations + ] + return PeopleGroupLocation.objects.bulk_create(locations_objs) + + class PeopleGroupLightSerializer( ModulesSerializers, AutoTranslatedModelSerializer, serializers.ModelSerializer ): @@ -396,7 +436,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 @@ -431,7 +471,7 @@ class PeopleGroupSerializer( serializers.ModelSerializer, ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "name", "description", "short_description", @@ -460,13 +500,13 @@ class PeopleGroupSerializer( 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, ) + locations = PeopleGroupLocationSerializer(many=True, required=False) - 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 = [] @@ -526,6 +566,8 @@ def validate_parent(self, value): def create(self, validated_data): team = validated_data.pop("team", {}) featured_projects = validated_data.pop("featured_projects", []) + locations = validated_data.pop("locations", []) + people_group = super(PeopleGroupSerializer, self).create(validated_data) PeopleGroupAddTeamMembersSerializer().create( {"people_group": people_group, **team} @@ -533,15 +575,17 @@ def create(self, validated_data): PeopleGroupAddFeaturedProjectsSerializer().create( {"people_group": people_group, "featured_projects": featured_projects} ) + PeopleGroupAddLocationsSerializer().create( + {"people_group": people_group, "locations": locations} + ) return people_group def update(self, instance, validated_data): validated_data.pop("team", {}) validated_data.pop("featured_projects", []) - return super(PeopleGroupSerializer, self).update(instance, validated_data) + validated_data.pop("locations", None) - def save(self, **kwargs): - return super().save(**kwargs) + return super(PeopleGroupSerializer, self).update(instance, validated_data) class Meta: model = PeopleGroup @@ -560,6 +604,7 @@ class Meta: "roles", "sdgs", "tags", + "locations", "publication_status", "team", "featured_projects", @@ -570,7 +615,7 @@ class Meta: class UserSerializer( StringsImagesSerializer, AutoTranslatedModelSerializer, serializers.ModelSerializer ): - string_images_forbid_fields: List[str] = [ + string_images_forbid_fields: list[str] = [ "description", "short_description", "job", @@ -737,7 +782,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 ( @@ -764,7 +809,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 = ( @@ -810,13 +855,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/tests/views/test_people_group.py b/apps/accounts/tests/views/test_people_group.py index 8e8d5aae..7353c07a 100644 --- a/apps/accounts/tests/views/test_people_group.py +++ b/apps/accounts/tests/views/test_people_group.py @@ -11,7 +11,7 @@ SeedUserFactory, UserFactory, ) -from apps.accounts.models import PeopleGroup +from apps.accounts.models import PeopleGroup, PeopleGroupLocation from apps.accounts.utils import get_superadmins_group from apps.commons.models import GroupData from apps.commons.test import JwtAPITestCase, TestRoles @@ -283,6 +283,30 @@ def test_create_people_group(self, role, expected_code): managers = self.managers leaders = self.leaders projects = self.projects + locations = [ + { + "title": "title-location", + "description": "description", + "lat": 54, + "lng": 32, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + { + "title": "title-location-2", + "description": "description-2", + "lat": 11, + "lng": 42, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + { + "title": "title-location-3", + "description": "description-3", + "lat": 65, + "lng": 52, + "type": PeopleGroupLocation.LocationType.ADDRESS, + }, + ] + user = self.get_parameterized_test_user(role, instances=[organization]) self.client.force_authenticate(user) payload = { @@ -296,6 +320,7 @@ def test_create_people_group(self, role, expected_code): "leaders": [r.id for r in leaders], }, "featured_projects": [p.pk for p in projects], + "locations": locations, } response = self.client.post( reverse("PeopleGroup-list", args=(organization.code,)), @@ -319,6 +344,16 @@ def test_create_people_group(self, role, expected_code): for project in projects: self.assertIn(project, people_group.featured_projects.all()) + actual_locations = sorted( + response.data["locations"], key=lambda x: x["title"] + ) + for idx, location in enumerate(actual_locations): + self.assertEqual(location["title"], locations[idx]["title"]) + self.assertEqual(location["description"], locations[idx]["description"]) + self.assertEqual(location["lat"], locations[idx]["lat"]) + self.assertEqual(location["lng"], locations[idx]["lng"]) + self.assertEqual(location["type"], locations[idx]["type"]) + class UpdatePeopleGroupTestCase(JwtAPITestCase): @classmethod @@ -1276,3 +1311,203 @@ def test_parent_update_on_parent_delete(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) child.refresh_from_db() self.assertEqual(child.parent, main_parent) + + +class TestPeopleGroupLocation(JwtAPITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.organization = OrganizationFactory() + cls.people_group = PeopleGroupFactory(organization=cls.organization) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_200_OK), + (TestRoles.DEFAULT, status.HTTP_200_OK), + (TestRoles.SUPERADMIN, status.HTTP_200_OK), + (TestRoles.ORG_ADMIN, status.HTTP_200_OK), + (TestRoles.ORG_FACILITATOR, status.HTTP_200_OK), + (TestRoles.ORG_USER, status.HTTP_200_OK), + (TestRoles.GROUP_LEADER, status.HTTP_200_OK), + (TestRoles.GROUP_MANAGER, status.HTTP_200_OK), + (TestRoles.GROUP_MEMBER, status.HTTP_200_OK), + ] + ) + def test_list(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + + # no locations set (from people_group factory) + url = reverse( + "PeopleGroup-detail", + args=(self.people_group.organization.code, self.people_group.pk), + ) + response = self.client.get(url) + data = response.json() + self.assertEqual(response.status_code, role_status_code) + self.assertEqual(data["locations"], []) + + payload = { + "title": "title", + "description": "description", + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, + } + PeopleGroupLocation.objects.create(people_group=self.people_group, **payload) + + response = self.client.get(url) + data = response.json() + self.assertEqual(response.status_code, role_status_code) + self.assertEqual(len(data["locations"]), 1) + self.assertEqual(data["locations"][0]["lat"], payload["lat"]) + self.assertEqual(data["locations"][0]["lng"], payload["lng"]) + self.assertEqual(data["locations"][0]["title"], payload["title"]) + self.assertEqual(data["locations"][0]["type"], payload["type"]) + self.assertIsNotNone(data["locations"][0]["id"]) + self.assertEqual(data["locations"][0]["people_group"], self.people_group.id) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_201_CREATED), + (TestRoles.ORG_ADMIN, status.HTTP_201_CREATED), + (TestRoles.ORG_FACILITATOR, status.HTTP_201_CREATED), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MANAGER, status.HTTP_201_CREATED), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_create(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + url = reverse( + "PeopleGroupLocations-list", + args=(self.people_group.organization.code, self.people_group.pk), + ) + + payload = { + "lat": 48.853183700426335, + "lng": 2.36428239939491, + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + } + response = self.client.post(url, data=payload) + data = response.json() + self.assertEqual(response.status_code, role_status_code) + + # no need to check more + if role_status_code != status.HTTP_201_CREATED: + return + + self.assertEqual(data["lat"], payload["lat"]) + self.assertEqual(data["lng"], payload["lng"]) + self.assertEqual(data["title"], payload["title"]) + self.assertEqual(data["type"], payload["type"]) + self.assertIsNotNone(data["id"]) + self.assertEqual(data["people_group"], self.people_group.id) + + # missings fiels 'lat/lng' to patch + payload = { + "title": "", + "type": PeopleGroupLocation.LocationType.ADDRESS.value, + } + response = self.client.post(url, data=payload) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_200_OK), + (TestRoles.ORG_ADMIN, status.HTTP_200_OK), + (TestRoles.ORG_FACILITATOR, status.HTTP_200_OK), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_200_OK), + (TestRoles.GROUP_MANAGER, status.HTTP_200_OK), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_update(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + payload = { + "title": "title", + "description": "description", + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, + } + location = PeopleGroupLocation.objects.create( + people_group=self.people_group, **payload + ) + url = reverse( + "PeopleGroupLocations-detail", + args=( + self.people_group.organization.code, + self.people_group.id, + location.id, + ), + ) + + # partial update + payload = { + "lat": 66, + } + response = self.client.patch(url, data=payload) + data = response.json() + self.assertEqual(response.status_code, role_status_code) + if role_status_code == status.HTTP_200_OK: + self.assertEqual(data["lat"], payload["lat"]) + + @parameterized.expand( + [ + (TestRoles.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (TestRoles.DEFAULT, status.HTTP_403_FORBIDDEN), + (TestRoles.SUPERADMIN, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_ADMIN, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_FACILITATOR, status.HTTP_204_NO_CONTENT), + (TestRoles.ORG_USER, status.HTTP_403_FORBIDDEN), + (TestRoles.GROUP_LEADER, status.HTTP_204_NO_CONTENT), + (TestRoles.GROUP_MANAGER, status.HTTP_204_NO_CONTENT), + (TestRoles.GROUP_MEMBER, status.HTTP_403_FORBIDDEN), + ] + ) + def test_delete(self, role, role_status_code): + user = self.get_parameterized_test_user( + role=role, instances=[self.people_group] + ) + self.client.force_authenticate(user) + payload = { + "title": "title", + "description": "description", + "lat": 99, + "lng": 11, + "type": PeopleGroupLocation.LocationType.ADDRESS, + } + location = PeopleGroupLocation.objects.create( + people_group=self.people_group, **payload + ) + url = reverse( + "PeopleGroupLocations-detail", + args=( + self.people_group.organization.code, + self.people_group.id, + location.id, + ), + ) + + response = self.client.delete(url) + self.assertEqual(response.status_code, role_status_code) + if role_status_code == status.HTTP_204_NO_CONTENT: + self.assertFalse( + PeopleGroupLocation.objects.filter(pk=location.id).exists() + ) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 7026bf83..784100e4 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -4,11 +4,15 @@ from apps.accounts.views import ( AccessTokenView, DeleteCookieView, + PeopleGroupLocationViewSet, PrivacySettingsViewSet, UserProfilePictureView, UserViewSet, ) -from apps.commons.urls import user_router_register +from apps.commons.urls import ( + organization_people_group_router_register, + user_router_register, +) from apps.feedbacks.views import ReviewViewSet, UserFollowViewSet router = DefaultRouter() @@ -21,6 +25,10 @@ router, r"profile-picture", UserProfilePictureView, basename="UserProfilePicture" ) +organization_people_group_router_register( + router, r"locations", PeopleGroupLocationViewSet, basename="PeopleGroupLocations" +) + urlpatterns = [ path("access-token/", AccessTokenView.as_view()), diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 23f40420..4a988010 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -41,12 +41,17 @@ RetrieveUpdateModelViewSet, ) from apps.commons.utils import map_action_to_permission -from apps.commons.views import DetailOnlyViewsetMixin, MultipleIDViewsetMixin +from apps.commons.views import ( + DetailOnlyViewsetMixin, + MultipleIDViewsetMixin, + NestedOrganizationViewMixins, + NestedPeopleGroupViewMixins, +) from apps.files.models import Image from apps.files.views import ImageStorageView from apps.organizations.models import Organization from apps.organizations.permissions import HasOrganizationPermission -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 ( @@ -61,7 +66,13 @@ from .exceptions import EmailTypeMissingError, PermissionNotFoundError from .filters import PeopleGroupFilter, UserFilter -from .models import AnonymousUser, PeopleGroup, PrivacySettings, ProjectUser +from .models import ( + AnonymousUser, + PeopleGroup, + PeopleGroupLocation, + PrivacySettings, + ProjectUser, +) from .parsers import UserMultipartParser from .permissions import HasBasePermission, HasPeopleGroupPermission from .serializers import ( @@ -72,6 +83,7 @@ PeopleGroupAddTeamMembersSerializer, PeopleGroupHierarchySerializer, PeopleGroupLightSerializer, + PeopleGroupLocationSerializer, PeopleGroupRemoveFeaturedProjectsSerializer, PeopleGroupRemoveTeamMembersSerializer, PeopleGroupSerializer, @@ -847,6 +859,53 @@ def similars(self, request, *args, **kwargs): ) return self.get_paginated_response(data.data) + @extend_schema(responses=LocationSerializer(many=True)) + @action( + detail=True, + methods=["GET"], + url_path="projects-locations", + permission_classes=[ReadOnly], + ) + def locations(self, request, *args, **kwargs): + group = self.get_object() + modules_manager = group.get_related_module() + modules = modules_manager(group, request.user) + queryset = modules.projects_locations() + + return Response( + LocationSerializer(queryset, many=True, context={"request": request}).data, + status=status.HTTP_200_OK, + ) + + +class PeopleGroupLocationViewSet( + NestedOrganizationViewMixins, NestedPeopleGroupViewMixins, viewsets.ModelViewSet +): + serializer_class = PeopleGroupLocationSerializer + + def get_permissions(self): + codename = map_action_to_permission(self.action, "peoplegroup") + if codename: + self.permission_classes = [ + IsAuthenticatedOrReadOnly, + ReadOnly + | HasBasePermission(codename, "accounts") + | HasOrganizationPermission(codename) + | HasPeopleGroupPermission(codename), + ] + return super().get_permissions() + + def get_queryset(self): + return PeopleGroupLocation.objects.filter(people_group=self.people_group) + + def update(self, request, *args, **kwargs): + request.data["people_group"] = self.people_group.id + return super().update(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + request.data["people_group"] = self.people_group.id + return super().create(request, *args, **kwargs) + @extend_schema( parameters=[OpenApiParameter("people_group_id", str, OpenApiParameter.PATH)] diff --git a/apps/commons/serializers.py b/apps/commons/serializers.py index c6679a7a..0e7a2dc0 100644 --- a/apps/commons/serializers.py +++ b/apps/commons/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db.models import Model, Q +from django.utils.translation import gettext_lazy as _ from modeltranslation.manager import get_translatable_fields_for_model from rest_framework import mixins, serializers, viewsets from rest_framework.settings import import_from_string @@ -11,6 +12,7 @@ from apps.files.models import Image from apps.organizations.models import Organization from apps.projects.models import Project +from services.translator.serializers import AutoTranslatedModelSerializer class ProjectRelatedSerializer(serializers.ModelSerializer): @@ -221,3 +223,35 @@ def save(self, **kwargs): return self.instance instance = super().save(**kwargs) return self.add_string_images_to_instance(instance, images) + + +class BaseLocationSerializer( + StringsImagesSerializer, + AutoTranslatedModelSerializer, + serializers.ModelSerializer, +): + string_images_forbid_fields: list[str] = ["title", "description"] + + class Meta: + fields = [ + "id", + "title", + "description", + "lat", + "lng", + "type", + ] + + def _check_gis(self, value): + """check gps coord num""" + if -90 <= value <= 90: + raise serializers.ValidationError( + _("The value must be between -90 and 90.") + ) + return value + + def valiate_lat(self, value): + return self._check_gis(super().validate_lat(value)) + + def valiate_lng(self, value): + return self._check_gis(super().validate_lng(value)) diff --git a/apps/modules/group.py b/apps/modules/group.py index 18109705..a9285361 100644 --- a/apps/modules/group.py +++ b/apps/modules/group.py @@ -2,7 +2,7 @@ from apps.accounts.models import PeopleGroup, ProjectUser from apps.modules.base import AbstractModules, register_module -from apps.projects.models import Project +from apps.projects.models import Location, Project from apps.skills.models import Skill from services.crisalid.models import Document, DocumentTypeCentralized @@ -69,6 +69,9 @@ def subgroups(self) -> QuerySet[PeopleGroup]: pk__in=self.user.get_people_group_queryset() ) + def projects_locations(self) -> QuerySet[Location]: + return Location.objects.filter(project__in=self.featured_projects()) + def _documents(self, documents_type: DocumentTypeCentralized) -> QuerySet[Document]: members_qs = self.members() return Document.objects.filter( diff --git a/apps/projects/migrations/0002_alter_location_type.py b/apps/projects/migrations/0002_alter_location_type.py new file mode 100644 index 00000000..553e2ac8 --- /dev/null +++ b/apps/projects/migrations/0002_alter_location_type.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.11 on 2026-02-12 09:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="location", + name="type", + field=models.CharField( + choices=[ + ("team", "Team"), + ("impact", "Impact"), + ("address", "Address"), + ], + default="team", + max_length=10, + ), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index b4cf08c9..a3ca415a 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -2,7 +2,7 @@ import math import os from functools import reduce -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, Optional import shortuuid as shortuuid from django.conf import settings @@ -119,9 +119,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 +351,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 +371,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()) @@ -611,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: @@ -697,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() @@ -726,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" @@ -759,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() @@ -809,7 +809,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" @@ -841,7 +841,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() @@ -850,9 +850,8 @@ def get_related_project(self) -> Optional["Project"]: return self.project -class Location( +class AbstractLocation( HasAutoTranslatedFields, - ProjectRelated, DuplicableModel, models.Model, ): @@ -876,32 +875,50 @@ 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, ) + +# TODO(remi): rename to ProjectLocation ? +class Location(ProjectRelated, AbstractLocation): + """A project location on Earth. + + Attributes + ---------- + id: Charfield + UUID4 used as the model's PK. + project: ForeignKey + Project at this location. + """ + + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="locations" + ) + def get_related_project(self) -> Optional["Project"]: """Return the projects related to this model.""" return self.project - 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() @@ -935,7 +952,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", @@ -967,7 +984,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() @@ -1007,7 +1024,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.""" @@ -1030,7 +1047,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() @@ -1055,7 +1072,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" @@ -1073,6 +1090,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 68f01402..9b3e3b1f 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -18,6 +18,7 @@ ) from apps.commons.models import GroupData from apps.commons.serializers import ( + BaseLocationSerializer, OrganizationRelatedSerializer, ProjectRelatedSerializer, StringsImagesSerializer, @@ -182,13 +183,10 @@ class Meta: class LocationSerializer( - StringsImagesSerializer, - AutoTranslatedModelSerializer, - OrganizationRelatedSerializer, ProjectRelatedSerializer, - serializers.ModelSerializer, + BaseLocationSerializer, ): - string_images_forbid_fields: List[str] = ["title", "description"] + string_images_forbid_fields: list[str] = ["title", "description"] project = LocationProjectSerializer(read_only=True) project_id = serializers.PrimaryKeyRelatedField( @@ -209,13 +207,7 @@ class Meta: "project_id", ] - def get_related_organizations(self) -> List[Organization]: - """Retrieve the related organizations""" - if "project" in self.validated_data: - return self.validated_data["project"].get_related_organizations() - return [] - - def get_related_project(self) -> Optional[Project]: + def get_related_project(self) -> Project | None: """Retrieve the related projects""" if "project" in self.validated_data: return self.validated_data["project"] diff --git a/apps/projects/tests/views/test_read_location.py b/apps/projects/tests/views/test_read_location.py index 8891eaee..9315f785 100644 --- a/apps/projects/tests/views/test_read_location.py +++ b/apps/projects/tests/views/test_read_location.py @@ -2,6 +2,10 @@ from parameterized import parameterized from rest_framework import status +from apps.accounts.factories import ( + PeopleGroupFactory, + PeopleGroupLocationFactory, +) from apps.commons.test import JwtAPITestCase, TestRoles from apps.organizations.factories import OrganizationFactory from apps.projects.factories import LocationFactory, ProjectFactory @@ -43,6 +47,37 @@ def setUpTestData(cls): "child": LocationFactory(project=cls.child_project), } + cls.public_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PUBLIC, + organization=cls.organization, + ) + cls.org_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.ORG, + organization=cls.organization, + ) + cls.private_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PRIVATE, + organization=cls.organization, + ) + cls.child_group = PeopleGroupFactory( + publication_status=Project.PublicationStatus.PUBLIC, + organization=cls.organization, + ) + + cls.groups = { + "public": cls.public_group, + "org": cls.org_group, + "private": cls.private_group, + "child": cls.child_group, + } + + cls.locations_group = { + "public": PeopleGroupLocationFactory(people_group=cls.public_group), + "org": PeopleGroupLocationFactory(people_group=cls.org_group), + "private": PeopleGroupLocationFactory(people_group=cls.private_group), + "child": PeopleGroupLocationFactory(people_group=cls.child_group), + } + @parameterized.expand( [ (TestRoles.ANONYMOUS, ("public", "child")), @@ -57,20 +92,23 @@ def setUpTestData(cls): (TestRoles.PROJECT_REVIEWER, ("public", "org", "private", "child")), ] ) - def test_retrieve_location(self, role, retrieved_locations): - for publication_status, location in self.locations.items(): - project = location.project - user = self.get_parameterized_test_user(role, instances=[project]) - self.client.force_authenticate(user) - response = self.client.get( - reverse("Read-location-detail", args=(location.id,)), - ) - if publication_status in retrieved_locations: - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json() - self.assertEqual(content["id"], location.id) - else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_list_project_location(self, role, retrieved_locations): + user = self.get_parameterized_test_user( + role, instances=[*self.projects.values()] + ) + self.client.force_authenticate(user) + response = self.client.get( + reverse("General-location-list", args=(self.organization.code,)), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + # projects + self.assertEqual(len(content["projects"]), len(retrieved_locations)) + self.assertSetEqual( + {a["id"] for a in content["projects"]}, + {a.id for a in [self.locations[a] for a in retrieved_locations]}, + ) @parameterized.expand( [ @@ -81,23 +119,22 @@ def test_retrieve_location(self, role, retrieved_locations): (TestRoles.ORG_FACILITATOR, ("public", "org", "private", "child")), (TestRoles.ORG_USER, ("public", "org", "child")), (TestRoles.ORG_VIEWER, ("public", "org", "child")), - (TestRoles.PROJECT_MEMBER, ("public", "org", "private", "child")), - (TestRoles.PROJECT_OWNER, ("public", "org", "private", "child")), - (TestRoles.PROJECT_REVIEWER, ("public", "org", "private", "child")), + (TestRoles.GROUP_MEMBER, ("public", "org", "private", "child")), + (TestRoles.GROUP_LEADER, ("public", "org", "private", "child")), + (TestRoles.GROUP_MANAGER, ("public", "org", "private", "child")), ] ) - def test_list_location(self, role, retrieved_locations): - user = self.get_parameterized_test_user( - role, instances=[*self.projects.values()] - ) + def test_list_group_location(self, role, retrieved_locations): + user = self.get_parameterized_test_user(role, instances=[*self.groups.values()]) self.client.force_authenticate(user) response = self.client.get( - reverse("Read-location-list"), + reverse("General-location-list", args=(self.organization.code,)), ) self.assertEqual(response.status_code, status.HTTP_200_OK) content = response.json() - self.assertEqual(len(content), len(retrieved_locations)) + + self.assertEqual(len(content["groups"]), len(retrieved_locations)) self.assertSetEqual( - {a["id"] for a in content}, - {a.id for a in [self.locations[a] for a in retrieved_locations]}, + {a["id"] for a in content["groups"]}, + {a.id for a in [self.locations_group[a] for a in retrieved_locations]}, ) 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..74504a59 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -13,11 +13,16 @@ from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.filters import OrderingFilter -from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly +from rest_framework.permissions import ( + IsAuthenticated, + IsAuthenticatedOrReadOnly, +) from rest_framework.response import Response from simple_history.utils import update_change_reason +from apps.accounts.models import PeopleGroupLocation from apps.accounts.permissions import HasBasePermission +from apps.accounts.serializers import PeopleGroupLocationSuperLightSerializer from apps.analytics.models import Stat from apps.commons.cache import clear_cache_with_key, redis_cache_view from apps.commons.permissions import IsOwner, ReadOnly @@ -43,7 +48,7 @@ ) from services.mistral.models import ProjectEmbedding -from .filters import LocationFilter, ProjectFilter +from .filters import ProjectFilter from .models import ( BlogEntry, Goal, @@ -612,11 +617,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 +1004,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": PeopleGroupLocationSuperLightSerializer(qs_group, many=True).data, + "projects": LocationSerializer(qs_project, many=True).data, + } + return Response(data, status=status.HTTP_200_OK) diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 0d661de0..39602a02 100644 --- a/locale/ca/LC_MESSAGES/django.po +++ b/locale/ca/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No pots assignar aquest rol a un usuari" msgid "You cannot assign this role to a user : {role}" msgstr "No pots assignar aquest rol a un usuari: {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilitat" @@ -190,6 +190,10 @@ msgstr "Id no vàlid \"{user_id}\" - l'objecte no existeix." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Tipus incorrecte. S'esperava un valor str, s'ha rebut {data_type}." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index f2488bf2..33445a72 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Sie können diese Rolle keinem Benutzer zuweisen" msgid "You cannot assign this role to a user : {role}" msgstr "Sie können diese Rolle keinem Benutzer zuweisen: {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "Sichtbarkeit" @@ -193,6 +193,10 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Falscher Typ. Erwartet wurde ein String-Wert, erhalten wurde {data_type}." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index c0c5f8a4..6fea3994 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-18 17:56+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "" msgid "You cannot assign this role to a user : {role}" msgstr "" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "" @@ -168,6 +168,10 @@ msgstr "" msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index f82e0e9a..b095e10d 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No puedes asignar este rol a un usuario" msgid "You cannot assign this role to a user : {role}" msgstr "No puedes asignar este rol a un usuario: {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilidad" @@ -191,6 +191,10 @@ msgid "Incorrect type. Expected str value, received {data_type}." msgstr "" "Tipo incorrecto. Se esperaba un valor de cadena, se recibió {data_type}." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 9edc1090..6e9b3aa3 100644 --- a/locale/et/LC_MESSAGES/django.po +++ b/locale/et/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "Sa ei saa seda rolli kasutajale määrata" msgid "You cannot assign this role to a user : {role}" msgstr "Sa ei saa seda rolli kasutajale määrata: {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "nähtavus" @@ -189,6 +189,10 @@ msgstr "Vigane ID \"{user_id}\" - objekti ei eksisteeri." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Vale tüüp. Oodati stringi väärtust, saadi {data_type}." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1614d084..613828fe 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice" msgid "You cannot assign this role to a user : {role}" msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice : {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "visibilité" @@ -192,6 +192,10 @@ msgstr "identifiant invalide \"{user_id}\" - cet objet n'existe pas." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Type incorrect. Valeur str attendue, {data_type} reçue." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14 diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 4566807f..f364b45a 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-19 13:59+0100\n" +"POT-Creation-Date: 2026-02-20 12:31+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Je kunt deze rol niet toewijzen aan een gebruiker" msgid "You cannot assign this role to a user : {role}" msgstr "Je kunt deze rol niet toewijzen aan een gebruiker: {role}" -#: apps/accounts/models.py:144 apps/projects/models.py:161 +#: apps/accounts/models.py:157 apps/projects/models.py:161 msgid "visibility" msgstr "zichtbaarheid" @@ -192,6 +192,10 @@ msgstr "Ongeldige id \"{user_id}\" - object bestaat niet." msgid "Incorrect type. Expected str value, received {data_type}." msgstr "Onjuist type. Verwachte stringwaarde, ontvangen {data_type}." +#: apps/commons/serializers.py:249 +msgid "The value must be between -90 and 90." +msgstr "" + #: apps/emailing/templates/contact/contact/email_with_name.html:13 #: apps/notifications/templates/new_application/mail.html:13 #: apps/notifications/templates/reminder/mail.html:14