From e9ec6aa4458d46dea5213ba471dcc5fe62150112 Mon Sep 17 00:00:00 2001 From: Girum Bizuayehu Date: Mon, 18 Nov 2024 16:00:43 +0300 Subject: [PATCH 1/3] Add HeaProfileViewSet and UserViewSet api endpoints see HEA-580 --- apps/common/migrations/0010_heaprofile.py | 52 +++++++++++++++++ apps/common/models.py | 21 +++++++ apps/common/serializers.py | 37 +++++++++++- apps/common/tests/factories.py | 8 +++ apps/common/tests/test_viewsets.py | 57 +++++++++++++++++++ apps/common/viewsets.py | 69 ++++++++++++++++++++++- hea/urls.py | 4 ++ 7 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 apps/common/migrations/0010_heaprofile.py diff --git a/apps/common/migrations/0010_heaprofile.py b/apps/common/migrations/0010_heaprofile.py new file mode 100644 index 00000000..48e248ce --- /dev/null +++ b/apps/common/migrations/0010_heaprofile.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.1 on 2024-11-17 15:34 + +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("common", "0009_countryclassifiedproductaliases_aliases"), + ] + + operations = [ + migrations.CreateModel( + name="HeaProfile", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ("expert", models.BooleanField(default=False)), + ("skip_tour", models.BooleanField(default=False)), + ("tour_last_viewed", models.DateField(null=True)), + ("livelihood_explorer_data", models.JSONField(blank=True, default=dict, null=True)), + ], + options={ + "verbose_name": "hea user profile", + "verbose_name_plural": "hea user profiles", + }, + ), + ] diff --git a/apps/common/models.py b/apps/common/models.py index e20c12db..a4c157f7 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -8,6 +8,7 @@ import operator from functools import reduce +from django.contrib.auth.models import User from django.core import validators from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -950,3 +951,23 @@ class Meta: fields=["country", "product"], name="common_countryclassified_country_code_product_code_uniq" ) ] + + +class HeaProfile(Model): + """ + A profile to store data associated with a user to be used by the Livelihoods Explorer + to create a dynamic user experience. + """ + + user = models.OneToOneField(User, on_delete=CASCADE, primary_key=True, unique=True) + expert = models.BooleanField(default=False) + skip_tour = models.BooleanField(default=False) + tour_last_viewed = models.DateField(null=True) + livelihood_explorer_data = models.JSONField(default=dict, null=True, blank=True) + + def __str__(self): + return f"hea_profile: {str(self.user)}" + + class Meta: + verbose_name = _("hea user profile") + verbose_name_plural = _("hea user profiles") diff --git a/apps/common/serializers.py b/apps/common/serializers.py index c79b413a..6e0f7c30 100644 --- a/apps/common/serializers.py +++ b/apps/common/serializers.py @@ -1,6 +1,7 @@ +from django.contrib.auth.models import User from rest_framework import serializers -from .models import ClassifiedProduct, Country, Currency, UnitOfMeasure +from .models import ClassifiedProduct, Country, Currency, HeaProfile, UnitOfMeasure class CountrySerializer(serializers.ModelSerializer): @@ -61,3 +62,37 @@ class Meta: "kcals_per_unit", "aliases", ] + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "first_name", "last_name"] + + +class CurrentUserSerializer(serializers.ModelSerializer): + permissions = serializers.ListField(source="get_all_permissions", read_only=True) + groups = serializers.SerializerMethodField() + + def get_groups(self, user): + return user.groups.values_list("name", flat=True) + + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "permissions", + "groups", + "is_staff", + "is_superuser", + ] + + +class HeaProfileSerializer(serializers.ModelSerializer): + class Meta: + model = HeaProfile + fields = ("user", "expert", "skip_tour", "tour_last_viewed", "livelihood_explorer_data") diff --git a/apps/common/tests/factories.py b/apps/common/tests/factories.py index a2ce0ec0..c49b9867 100644 --- a/apps/common/tests/factories.py +++ b/apps/common/tests/factories.py @@ -60,6 +60,14 @@ def groups(self, create, extracted, **kwargs): self.groups.add(group) +class HeaProfileFactory(factory.django.DjangoModelFactory): + class Meta: + model = "common.HeaProfile" + django_get_or_create = ("user",) + + user = factory.SubFactory(UserFactory) + + class GroupFactory(factory.django.DjangoModelFactory): class Meta: model = "auth.Group" diff --git a/apps/common/tests/test_viewsets.py b/apps/common/tests/test_viewsets.py index 4f51565d..354a4ea3 100644 --- a/apps/common/tests/test_viewsets.py +++ b/apps/common/tests/test_viewsets.py @@ -9,6 +9,7 @@ ClassifiedProductFactory, CountryFactory, CurrencyFactory, + HeaProfileFactory, UnitOfMeasureFactory, UserFactory, ) @@ -190,3 +191,59 @@ def test_search_fields(self): self.assertEqual(response.status_code, 200) result = json.loads(response.content.decode("utf-8")) self.assertEqual(len(result), 1) + + +class UserViewSetTestCase(APITestCase): + def setUp(self): + self.user = UserFactory(username="testuser", password="password123", first_name="Test", last_name="User") + self.client.force_authenticate(user=self.user) + self.url = reverse("user-list") + + def test_get_current_user(self): + response = self.client.get(f"{self.url}current/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["username"], self.user.username) + + def test_search_users(self): + UserFactory(username="searchuser", password="password123", first_name="Search", last_name="User") + response = self.client.get(self.url, {"search": "Search"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["first_name"], "Search") + + +class HeaProfileViewSetTestCase(APITestCase): + def setUp(self): + self.user = UserFactory(username="testuser", password="password123") + self.profile = HeaProfileFactory(user=self.user) + self.client.force_authenticate(user=self.user) + self.url = reverse("heaprofile-list") + + def test_get_current_profile(self): + response = self.client.get(f"{self.url}current/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["user"], self.user.id) + + def test_superuser_access_profiles(self): + superuser = UserFactory(username="admin", password="password123", is_superuser=True) + self.client.force_authenticate(user=superuser) + response = self.client.get(f"{self.url}{self.profile.user.id}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["user"], self.user.id) + + def test_queryset_filters(self): + other_user = UserFactory(username="otheruser", password="password123") + HeaProfileFactory(user=other_user) + + # Current user profile only + response = self.client.get(f"{self.url}?pk=current") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["user"], self.user.id) + + # Superuser access to all profiles + superuser = UserFactory(username="admin", password="password123", is_superuser=True) + self.client.force_authenticate(user=superuser) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertGreaterEqual(len(response.data), 2) diff --git a/apps/common/viewsets.py b/apps/common/viewsets.py index 5b32ffed..c7759039 100644 --- a/apps/common/viewsets.py +++ b/apps/common/viewsets.py @@ -1,18 +1,24 @@ +from django.contrib.auth.models import User from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.exceptions import NotAcceptable from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import BasePermission, IsAuthenticated from .fields import translation_fields from .filters import MultiFieldFilter -from .models import ClassifiedProduct, Country, Currency, UnitOfMeasure +from .models import ClassifiedProduct, Country, Currency, HeaProfile, UnitOfMeasure from .serializers import ( ClassifiedProductSerializer, CountrySerializer, CurrencySerializer, + CurrentUserSerializer, + HeaProfileSerializer, UnitOfMeasureSerializer, + UserSerializer, ) @@ -323,3 +329,64 @@ class ClassifiedProductViewSet(BaseModelViewSet): *translation_fields("description"), *translation_fields("common_name"), ) + + +class CurrentUserOnly(BasePermission): + def has_permission(self, request, view): + if request.user.is_superuser: + return True + elif view.kwargs == {"pk": "current"}: + # Even anonymous users can see their current user record + return True + elif request.query_params.get("pk") == "current": + # List views seem to use query_params rather than kwargs + return True + return False + + +class UserViewSet(BaseModelViewSet): + """ + Allows users to be viewed or edited. + """ + + queryset = User.objects.all() + permission_classes = [CurrentUserOnly] + serializer_class = UserSerializer + search_fields = ["username", "first_name", "last_name"] + + def get_object(self): + pk = self.kwargs.get("pk") + + if pk == "current": + self.serializer_class = CurrentUserSerializer + return self.request.user if self.request.user.id else User.get_anonymous() + + return super().get_object() + + @action(detail=True, methods=["get"]) + def current(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +class HeaProfileViewSet(BaseModelViewSet): + queryset = HeaProfile.objects.all() + serializer_class = HeaProfileSerializer + permission_classes = [CurrentUserOnly, IsAuthenticated] + + def get_object(self): + pk = self.kwargs.get("pk") + if pk == "current": + return self.request.user.heaprofile if self.request.user.id else None + return super().get_object() + + def get_queryset(self): + queryset = super().get_queryset() + pk = self.request.query_params.get("pk") or self.kwargs.get("pk") + + if pk == "current": + return queryset.filter(user=self.request.user.id) + elif pk: + # Superusers can access profiles without using pk=current. + return queryset.filter(user=pk) + else: + return queryset diff --git a/hea/urls.py b/hea/urls.py index 1cc6733e..e2c9b8f4 100644 --- a/hea/urls.py +++ b/hea/urls.py @@ -49,7 +49,9 @@ ClassifiedProductViewSet, CountryViewSet, CurrencyViewSet, + HeaProfileViewSet, UnitOfMeasureViewSet, + UserViewSet, ) from metadata.viewsets import ( HazardCategoryViewSet, @@ -67,6 +69,8 @@ router.register(r"currency", CurrencyViewSet) router.register(r"unitofmeasure", UnitOfMeasureViewSet) router.register(r"classifiedproduct", ClassifiedProductViewSet) +router.register(r"user", UserViewSet) +router.register(r"heaprofile", HeaProfileViewSet) # Metadata router.register(r"livelihoodcategory", LivelihoodCategoryViewSet) From ba39d57ac9a986e64cc3fbdac18d6dce9d36397b Mon Sep 17 00:00:00 2001 From: Girum Bizuayehu Date: Fri, 22 Nov 2024 09:20:58 +0300 Subject: [PATCH 2/3] Rename model and viewsets from HeaProfile to UserProfile and address PR feedback see HEA-580 --- .../{0010_heaprofile.py => 0010_userprofile.py} | 13 +++++-------- apps/common/models.py | 16 ++++++---------- apps/common/serializers.py | 8 ++++---- apps/common/tests/factories.py | 4 ++-- apps/common/tests/test_viewsets.py | 10 +++++----- apps/common/viewsets.py | 12 ++++++------ hea/urls.py | 4 ++-- 7 files changed, 30 insertions(+), 37 deletions(-) rename apps/common/migrations/{0010_heaprofile.py => 0010_userprofile.py} (73%) diff --git a/apps/common/migrations/0010_heaprofile.py b/apps/common/migrations/0010_userprofile.py similarity index 73% rename from apps/common/migrations/0010_heaprofile.py rename to apps/common/migrations/0010_userprofile.py index 48e248ce..1901aa86 100644 --- a/apps/common/migrations/0010_heaprofile.py +++ b/apps/common/migrations/0010_userprofile.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-11-17 15:34 +# Generated by Django 5.0.2 on 2024-11-22 06:07 import django.db.models.deletion import django.utils.timezone @@ -16,7 +16,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="HeaProfile", + name="UserProfile", fields=[ ( "created", @@ -39,14 +39,11 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), - ("expert", models.BooleanField(default=False)), - ("skip_tour", models.BooleanField(default=False)), - ("tour_last_viewed", models.DateField(null=True)), - ("livelihood_explorer_data", models.JSONField(blank=True, default=dict, null=True)), + ("profile_data", models.JSONField(blank=True, default=dict, null=True)), ], options={ - "verbose_name": "hea user profile", - "verbose_name_plural": "hea user profiles", + "verbose_name": "user profile", + "verbose_name_plural": "user profiles", }, ), ] diff --git a/apps/common/models.py b/apps/common/models.py index a4c157f7..c19e0afb 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -953,21 +953,17 @@ class Meta: ] -class HeaProfile(Model): +class UserProfile(Model): """ - A profile to store data associated with a user to be used by the Livelihoods Explorer - to create a dynamic user experience. + A profile to store data associated with a user to enable a customized user experience """ user = models.OneToOneField(User, on_delete=CASCADE, primary_key=True, unique=True) - expert = models.BooleanField(default=False) - skip_tour = models.BooleanField(default=False) - tour_last_viewed = models.DateField(null=True) - livelihood_explorer_data = models.JSONField(default=dict, null=True, blank=True) + profile_data = models.JSONField(default=dict, null=True, blank=True) def __str__(self): - return f"hea_profile: {str(self.user)}" + return f"user_profile: {str(self.user)}" class Meta: - verbose_name = _("hea user profile") - verbose_name_plural = _("hea user profiles") + verbose_name = _("user profile") + verbose_name_plural = _("user profiles") diff --git a/apps/common/serializers.py b/apps/common/serializers.py index 6e0f7c30..7d13730e 100644 --- a/apps/common/serializers.py +++ b/apps/common/serializers.py @@ -1,7 +1,7 @@ from django.contrib.auth.models import User from rest_framework import serializers -from .models import ClassifiedProduct, Country, Currency, HeaProfile, UnitOfMeasure +from .models import ClassifiedProduct, Country, Currency, UnitOfMeasure, UserProfile class CountrySerializer(serializers.ModelSerializer): @@ -92,7 +92,7 @@ class Meta: ] -class HeaProfileSerializer(serializers.ModelSerializer): +class UserProfileSerializer(serializers.ModelSerializer): class Meta: - model = HeaProfile - fields = ("user", "expert", "skip_tour", "tour_last_viewed", "livelihood_explorer_data") + model = UserProfile + fields = ("user", "profile_data") diff --git a/apps/common/tests/factories.py b/apps/common/tests/factories.py index c49b9867..2d97f412 100644 --- a/apps/common/tests/factories.py +++ b/apps/common/tests/factories.py @@ -60,9 +60,9 @@ def groups(self, create, extracted, **kwargs): self.groups.add(group) -class HeaProfileFactory(factory.django.DjangoModelFactory): +class UserProfileFactory(factory.django.DjangoModelFactory): class Meta: - model = "common.HeaProfile" + model = "common.UserProfile" django_get_or_create = ("user",) user = factory.SubFactory(UserFactory) diff --git a/apps/common/tests/test_viewsets.py b/apps/common/tests/test_viewsets.py index 354a4ea3..285c3c13 100644 --- a/apps/common/tests/test_viewsets.py +++ b/apps/common/tests/test_viewsets.py @@ -9,9 +9,9 @@ ClassifiedProductFactory, CountryFactory, CurrencyFactory, - HeaProfileFactory, UnitOfMeasureFactory, UserFactory, + UserProfileFactory, ) @@ -212,12 +212,12 @@ def test_search_users(self): self.assertEqual(response.data[0]["first_name"], "Search") -class HeaProfileViewSetTestCase(APITestCase): +class UserProfileViewSetTestCase(APITestCase): def setUp(self): self.user = UserFactory(username="testuser", password="password123") - self.profile = HeaProfileFactory(user=self.user) + self.profile = UserProfileFactory(user=self.user) self.client.force_authenticate(user=self.user) - self.url = reverse("heaprofile-list") + self.url = reverse("userprofile-list") def test_get_current_profile(self): response = self.client.get(f"{self.url}current/") @@ -233,7 +233,7 @@ def test_superuser_access_profiles(self): def test_queryset_filters(self): other_user = UserFactory(username="otheruser", password="password123") - HeaProfileFactory(user=other_user) + UserProfileFactory(user=other_user) # Current user profile only response = self.client.get(f"{self.url}?pk=current") diff --git a/apps/common/viewsets.py b/apps/common/viewsets.py index c7759039..e36f4547 100644 --- a/apps/common/viewsets.py +++ b/apps/common/viewsets.py @@ -10,14 +10,14 @@ from .fields import translation_fields from .filters import MultiFieldFilter -from .models import ClassifiedProduct, Country, Currency, HeaProfile, UnitOfMeasure +from .models import ClassifiedProduct, Country, Currency, UnitOfMeasure, UserProfile from .serializers import ( ClassifiedProductSerializer, CountrySerializer, CurrencySerializer, CurrentUserSerializer, - HeaProfileSerializer, UnitOfMeasureSerializer, + UserProfileSerializer, UserSerializer, ) @@ -368,15 +368,15 @@ def current(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) -class HeaProfileViewSet(BaseModelViewSet): - queryset = HeaProfile.objects.all() - serializer_class = HeaProfileSerializer +class UserProfileViewSet(BaseModelViewSet): + queryset = UserProfile.objects.all() + serializer_class = UserProfileSerializer permission_classes = [CurrentUserOnly, IsAuthenticated] def get_object(self): pk = self.kwargs.get("pk") if pk == "current": - return self.request.user.heaprofile if self.request.user.id else None + return self.request.user.userprofile if self.request.user.id else None return super().get_object() def get_queryset(self): diff --git a/hea/urls.py b/hea/urls.py index e2c9b8f4..c46df717 100644 --- a/hea/urls.py +++ b/hea/urls.py @@ -49,9 +49,9 @@ ClassifiedProductViewSet, CountryViewSet, CurrencyViewSet, - HeaProfileViewSet, UnitOfMeasureViewSet, UserViewSet, + UserProfileViewSet, ) from metadata.viewsets import ( HazardCategoryViewSet, @@ -70,7 +70,7 @@ router.register(r"unitofmeasure", UnitOfMeasureViewSet) router.register(r"classifiedproduct", ClassifiedProductViewSet) router.register(r"user", UserViewSet) -router.register(r"heaprofile", HeaProfileViewSet) +router.register(r"userprofile", UserProfileViewSet) # Metadata router.register(r"livelihoodcategory", LivelihoodCategoryViewSet) From ff9fc0946336cf513927282a6931dc2b1458ab1f Mon Sep 17 00:00:00 2001 From: Girum Bizuayehu Date: Fri, 22 Nov 2024 09:31:13 +0300 Subject: [PATCH 3/3] Rename model and viewsets from HeaProfile to UserProfile and address PR feedback see HEA-580 --- hea/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hea/urls.py b/hea/urls.py index c46df717..20c04098 100644 --- a/hea/urls.py +++ b/hea/urls.py @@ -50,8 +50,8 @@ CountryViewSet, CurrencyViewSet, UnitOfMeasureViewSet, - UserViewSet, UserProfileViewSet, + UserViewSet, ) from metadata.viewsets import ( HazardCategoryViewSet,