From 258eba14fa817e9b109aba4be0b12bdd11574c7c Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 5 Jan 2022 15:13:04 +0100
Subject: [PATCH 3/3] api: add a user profile management endpoint (#58554)

---
 src/authentic2/api_urls.py  |   5 ++
 src/authentic2/api_views.py | 100 +++++++++++++++++++++++++++++++++++-
 tests/test_api.py           |  90 ++++++++++++++++++++++++++++++++
 3 files changed, 194 insertions(+), 1 deletion(-)

diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py
index 232a0451..cbc25950 100644
--- a/src/authentic2/api_urls.py
+++ b/src/authentic2/api_urls.py
@@ -32,6 +32,11 @@ urlpatterns = [
         api_views.role_membership,
         name='a2-api-role-member',
     ),
+    url(
+        r'^users/(?P<user_uuid>[\w+]*)/profiles/(?P<profile_type_slug>[^/]+)/$',
+        api_views.user_profiles,
+        name='a2-api-user-profiles',
+    ),
     url(
         r'^roles/(?P<role_uuid>[\w+]*)/relationships/members/$',
         api_views.role_memberships,
diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index fa27fc56..0decf9d1 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -57,7 +57,7 @@ from authentic2.compat.drf import action
 from . import api_mixins, app_settings, decorators, hooks
 from .a2_rbac.models import OrganizationalUnit, Role
 from .a2_rbac.utils import get_default_ou
-from .custom_user.models import User
+from .custom_user.models import Profile, ProfileType, User
 from .journal_event_types import UserLogin, UserRegistration
 from .models import Attribute, PasswordReset, Service
 from .passwords import get_password_checker
@@ -953,6 +953,104 @@ class RolesMembersAPI(UsersAPI):
 roles_members = RolesMembersAPI.as_view({'get': 'list'})
 
 
+class ProfileSerializer(serializers.ModelSerializer):
+    data = serializers.JSONField(binary=False)
+
+    def create(self, validated_data):
+        return Profile(**validated_data)
+
+    def update(self, validated_data):
+        # not supported yet
+        pass
+
+    class Meta:
+        model = Profile
+        fields = ('data',)
+
+
+class UserProfilesAPI(ExceptionHandlerMixin, APIView):
+    permission_classes = (permissions.IsAuthenticated,)
+    serializer_class = ProfileSerializer
+
+    def initial(self, request, *args, **kwargs):
+        super().initial(request, *args, **kwargs)
+        User = get_user_model()
+        self.profile_type = get_object_or_404(ProfileType, slug=kwargs['profile_type_slug'])
+        self.user = get_object_or_404(User, uuid=kwargs['user_uuid'])
+
+    def get(self, request, *args, **kwargs):
+        profile = get_object_or_404(Profile, user=self.user, profile_type=self.profile_type)
+        if not request.user.has_perm('custom_user.view_profile', obj=profile):
+            raise PermissionDenied('User not allowed to view user profiles')
+        return Response(self.serializer_class(profile).data)
+
+    def post(self, request, *args, **kwargs):
+        if not request.user.has_perm('custom_user.create_profile'):
+            raise PermissionDenied('User not allowed to create user profiles')
+        serializer = self.serializer_class(data=request.data)
+        if not serializer.is_valid():
+            response = {'data': [], 'err': 1, 'err_desc': serializer.errors}
+            return Response(response, status.HTTP_400_BAD_REQUEST)
+        try:
+            profile = Profile.objects.get(user=self.user, profile_type=self.profile_type)
+        except Profile.DoesNotExist:
+            profile = serializer.save()
+            profile.profile_type = self.profile_type
+            profile.user = self.user
+            profile.save()  # fixme double db access
+            request.journal.record(
+                'user.profile.add',
+                user=request.user,
+                profile_type=self.profile_type,
+                object_user=self.user,
+                api=True,
+            )
+            return Response(
+                {'result': 1, 'detail': _('Profile successfully assigned to user')}, status=status.HTTP_200_OK
+            )
+
+    def patch(self, request, *args, **kwargs):
+        serializer = self.serializer_class(data=request.data)
+        if not serializer.is_valid():
+            response = {'data': [], 'err': 1, 'err_desc': serializer.errors}
+            return Response(response, status.HTTP_400_BAD_REQUEST)
+        profile = get_object_or_404(Profile, user=self.user, profile_type=self.profile_type)
+        if not request.user.has_perm('custom_user.change_profile', obj=profile):
+            raise PermissionDenied('User not allowed to change user profiles')
+        profile.data = request.data.get('data', {})
+        profile.save()
+        request.journal.record(
+            'user.profile.update',
+            user=request.user,
+            profile_type=self.profile_type,
+            object_user=self.user,
+            api=True,
+        )
+        return Response({'result': 1, 'detail': _('Profile successfully updated')}, status=status.HTTP_200_OK)
+
+    def put(self, request, *args, **kwargs):
+        return self.patch(request, *args, **kwargs)
+
+    def delete(self, request, *args, **kwargs):
+        profile = get_object_or_404(Profile, user=self.user, profile_type=self.profile_type)
+        if not request.user.has_perm('custom_user.delete_profile', obj=profile):
+            raise PermissionDenied('User not allowed to delete user profiles')
+        request.journal.record(
+            'user.profile.delete',
+            user=request.user,
+            profile_type=self.profile_type,
+            object_user=self.user,
+            api=True,
+        )
+        profile.delete()
+        return Response(
+            {'result': 1, 'detail': _('Profile successfully removed from user')}, status=status.HTTP_200_OK
+        )
+
+
+user_profiles = UserProfilesAPI.as_view()
+
+
 class RoleMembershipAPI(ExceptionHandlerMixin, APIView):
     permission_classes = (permissions.IsAuthenticated,)
 
diff --git a/tests/test_api.py b/tests/test_api.py
index e84da1cb..ec0610c3 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -36,6 +36,7 @@ from authentic2.a2_rbac.models import OrganizationalUnit as OU
 from authentic2.a2_rbac.models import Role
 from authentic2.a2_rbac.utils import get_default_ou
 from authentic2.apps.journal.models import Event, EventType
+from authentic2.custom_user.models import Profile, ProfileType
 from authentic2.models import Attribute, AttributeValue, AuthorizedRole, PasswordReset, Service
 from authentic2.utils.misc import good_next_url
 from django_rbac.models import SEARCH_OP
@@ -2617,3 +2618,92 @@ def test_users_page_size(app, admin):
 
     resp = app.get('/api/users/?limit=1')
     assert len(resp.json['results']) == 1
+
+
+def test_user_profile_get(app, superuser):
+    app.authorization = ('Basic', (superuser.username, superuser.username))
+
+    user = User.objects.create(username='john.doe', email='john.doe@example.com', ou=get_default_ou())
+
+    # wrong type
+    resp = app.get('/api/users/%s/profiles/non-existant-slug/' % user.uuid, status=404)
+
+    profile_type = ProfileType.objects.create(slug='one-type', name='One Type')
+
+    # wrong user
+    resp = app.get('/api/users/1234/profiles/one-type/', status=404)
+
+    Profile.objects.create(profile_type=profile_type, user=user, data={'foo': 'bar'})
+    resp = app.get('/api/users/%s/profiles/one-type/' % user.uuid)
+    assert resp.json == {'data': {'foo': 'bar'}}
+
+
+def test_user_profile_put(app, superuser):
+    app.authorization = ('Basic', (superuser.username, superuser.username))
+
+    user = User.objects.create(username='john.doe', email='john.doe@example.com', ou=get_default_ou())
+
+    # wrong type
+    resp = app.put('/api/users/%s/profiles/non-existant-slug/' % user.uuid, status=404)
+
+    profile_type = ProfileType.objects.create(slug='one-type', name='One Type')
+
+    # wrong user
+    resp = app.put('/api/users/1234/profiles/one-type/', status=404)
+
+    # missing profile
+    resp = app.put_json(
+        '/api/users/%s/profiles/one-type/' % user.uuid, params={'data': {'foo': 'bar'}}, status=404
+    )
+
+    Profile.objects.create(user=user, profile_type=profile_type, data={})
+
+    resp = app.put_json('/api/users/%s/profiles/one-type/' % user.uuid, params={'data': {'foo': 'bar'}})
+    assert resp.json == {'result': 1, 'detail': 'Profile successfully updated'}
+    assert len(user.subprofiles.all()) == 1
+    assert user.subprofiles.last().data == {'foo': 'bar'}
+
+    # attempt at overwriting profile data
+    resp = app.put_json('/api/users/%s/profiles/one-type/' % user.uuid, params={'data': {'baz': 'bob'}})
+
+    # profile has been updated, no extra profile has been created
+    assert resp.json == {'result': 1, 'detail': 'Profile successfully updated'}
+    assert len(user.subprofiles.all()) == 1
+    assert user.subprofiles.last().data == {'baz': 'bob'}
+
+
+def test_user_profile_post(app, superuser):
+    app.authorization = ('Basic', (superuser.username, superuser.username))
+
+    user = User.objects.create(username='john.doe', email='john.doe@example.com', ou=get_default_ou())
+
+    # wrong type
+    resp = app.get('/api/users/%s/profiles/non-existant-slug/' % user.uuid, status=404)
+
+    ProfileType.objects.create(slug='one-type', name='One Type')
+
+    # wrong user
+    resp = app.get('/api/users/1234/profiles/one-type/', status=404)
+
+    assert len(user.subprofiles.all()) == 0
+
+    resp = app.post_json('/api/users/%s/profiles/one-type/' % user.uuid, params={'data': {'baz': 'bob'}})
+    assert resp.json == {'result': 1, 'detail': 'Profile successfully assigned to user'}
+    assert len(user.subprofiles.all()) == 1
+    assert user.subprofiles.last().data == {'baz': 'bob'}
+
+
+def test_user_profile_delete(app, superuser):
+
+    app.authorization = ('Basic', (superuser.username, superuser.username))
+
+    user = User.objects.create(username='john.doe', email='john.doe@example.com', ou=get_default_ou())
+    profile_type = ProfileType.objects.create(slug='one-type', name='One Type')
+    Profile.objects.create(user=user, profile_type=profile_type)
+
+    app.delete('/api/users/%s/profiles/non-existant-slug/' % user.uuid, status=404)
+    app.delete('/api/users/%s/profiles/one-type/' % user.uuid)
+
+    assert not Profile.objects.filter(user=user, profile_type=profile_type)
+
+    app.delete('/api/users/%s/profiles/one-type/' % user.uuid, status=404)
-- 
2.34.1

