Repository: mdn/kuma

Issue:
T - user details v1 API endpoint
**Summary**
So you can use the new Account Settings (user details) page via an API. 
It needs to support getting as well as updating the user details. 


**Proposal**
The following user data fields are needed:

* `is_newsletter_subscribed`
* `username`
* `fullname`
* `locale`


**Additional context**
A prototype of the new user details (as part of account settings) is already in master except it doesn't prefill the from server data and it doesn't have a submit handler. 



Discussion:
Note, that the `username` is already retrieved from the `whoami` but API endpoint needs to support *updating* it.
The estimate assumes changing the username is painless. 

Resolving patch:
diff --git a/kuma/api/v1/serializers.py b/kuma/api/v1/serializers.py
--- a/kuma/api/v1/serializers.py
+++ b/kuma/api/v1/serializers.py
@@ -1,6 +1,7 @@
 from rest_framework import exceptions
 from rest_framework import serializers
 
+from kuma.users.models import User
 from kuma.wiki.models import BCSignal, Document
 
 
@@ -26,3 +27,9 @@ def create(self, validated_data):
         if document:
             return BCSignal.objects.create(document=document, **validated_data)
         raise exceptions.ValidationError("Document not found")
+
+
+class UserDetailsSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = User
+        fields = ("username", "fullname", "is_newsletter_subscribed", "locale")
diff --git a/kuma/api/v1/urls.py b/kuma/api/v1/urls.py
--- a/kuma/api/v1/urls.py
+++ b/kuma/api/v1/urls.py
@@ -14,5 +14,6 @@
     ),
     path("subscriptions/", views.subscriptions, name="api.v1.subscriptions"),
     path("stripe_hooks/", views.stripe_hooks, name="api.v1.stripe_hooks"),
+    path("user_details/", views.user_details, name="api.v1.user_details"),
     path("sendinblue_hooks/", views.sendinblue_hooks, name="api.v1.sendinblue_hooks"),
 ]
diff --git a/kuma/api/v1/views.py b/kuma/api/v1/views.py
--- a/kuma/api/v1/views.py
+++ b/kuma/api/v1/views.py
@@ -19,13 +19,15 @@
 from raven.contrib.django.models import client as raven_client
 from rest_framework import serializers, status
 from rest_framework.decorators import api_view
+from rest_framework.permissions import IsAuthenticated
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
+from rest_framework.views import APIView
 from waffle import flag_is_active
 from waffle.decorators import waffle_flag
 from waffle.models import Flag, Sample, Switch
 
-from kuma.api.v1.serializers import BCSignalSerializer
+from kuma.api.v1.serializers import BCSignalSerializer, UserDetailsSerializer
 from kuma.core.email_utils import render_email
 from kuma.core.ga_tracking import (
     ACTION_SUBSCRIPTION_CANCELED,
@@ -46,6 +48,7 @@
 from kuma.search.search import SearchView
 from kuma.users.models import User, UserSubscription
 from kuma.users.newsletter.utils import refresh_is_user_newsletter_subscribed
+from kuma.users.signals import newsletter_subscribed, newsletter_unsubscribed
 from kuma.users.stripe_utils import (
     cancel_stripe_customer_subscriptions,
     create_stripe_customer_and_subscription_for_user,
@@ -287,6 +290,36 @@ def _download_from_url(url):
     return pdf_download.content
 
 
+class APIUserDetailsView(APIView):
+    http_method_names = ["get", "put"]
+    serializer_class = UserDetailsSerializer
+    renderer_classes = [JSONRenderer]
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request, format=None):
+        assert request.user.is_authenticated
+        serializer = UserDetailsSerializer(request.user, many=False)
+        return Response(serializer.data)
+
+    def put(self, request, format=None):
+        user = request.user
+        serializer = UserDetailsSerializer(instance=user, data=request.data)
+        if serializer.is_valid():
+            was_subscribed = user.is_newsletter_subscribed
+            serializer.save(user=user)
+
+            if not was_subscribed and user.is_newsletter_subscribed:
+                newsletter_subscribed.send(None, user=user)
+            if was_subscribed and not user.is_newsletter_subscribed:
+                newsletter_unsubscribed.send(None, user=user)
+
+            return Response(serializer.data, status=status.HTTP_200_OK)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+user_details = never_cache(APIUserDetailsView.as_view())
+
+
 @csrf_exempt
 @require_POST
 @never_cache


Test patch:
diff --git a/kuma/api/v1/tests/test_views.py b/kuma/api/v1/tests/test_views.py
--- a/kuma/api/v1/tests/test_views.py
+++ b/kuma/api/v1/tests/test_views.py
@@ -625,6 +625,150 @@ def test_stripe_subscription_canceled_sends_ga_tracking(
     )
 
 
+@pytest.mark.django_db
+def test_user_details_logged_in(client):
+    response = client.get(reverse("api.v1.user_details"))
+    assert response.status_code == 403
+    assert_no_cache_header(response)
+
+
+@pytest.mark.django_db
+def test_user_details_happy_path(user_client, wiki_user):
+    # There are dedicated tests to toggling your 'is_newsletter_subscribed'
+    # but don't want to trigger that in this test. So set it to False.
+    wiki_user.is_newsletter_subscribed = False
+    wiki_user.save()
+
+    response = user_client.get(reverse("api.v1.user_details"))
+    assert response.status_code == 200
+    assert_no_cache_header(response)
+    assert response.json()["username"] == wiki_user.username
+    assert response.json()["fullname"] == wiki_user.fullname
+    assert (
+        response.json()["is_newsletter_subscribed"]
+        == wiki_user.is_newsletter_subscribed
+    )
+    assert response.json()["locale"] == wiki_user.locale
+
+    response = user_client.put(
+        reverse("api.v1.user_details"),
+        content_type="application/json",
+        data={
+            "fullname": "Art Vandelay",
+            "username": "art",
+            "is_newsletter_subscribed": False,
+            "locale": "sv-SE",
+        },
+    )
+    assert response.status_code == 200
+    assert response.json()["username"] == "art"
+    assert response.json()["fullname"] == "Art Vandelay"
+    assert response.json()["is_newsletter_subscribed"] is False
+    assert response.json()["locale"] == "sv-SE"
+
+    wiki_user.refresh_from_db()
+    assert wiki_user.username == "art"
+    assert wiki_user.fullname == "Art Vandelay"
+    assert wiki_user.is_newsletter_subscribed is False
+    assert wiki_user.locale == "sv-SE"
+
+
+@pytest.mark.django_db
+def test_user_details_invalid_username(user_client, wiki_user, django_user_model):
+    def put():
+        return user_client.put(
+            reverse("api.v1.user_details"), content_type="application/json", data=data,
+        )
+
+    # Empty username
+    data = {
+        "fullname": wiki_user.fullname,
+        "username": "   ",
+        "is_newsletter_subscribed": False,
+        "locale": "sv-SE",
+    }
+    response = put()
+    assert response.status_code == 400
+    assert response.json()["username"]
+
+    # Username present but when stripped an empty string
+    data["username"] = "  \t  "
+    response = put()
+    assert response.status_code == 400
+    assert response.json()["username"]
+
+    django_user_model.objects.create(
+        username="washerefirst", email="washerefirst@example.com",
+    )
+    # Username taken by someone else
+    data["username"] = "washerefirst"
+    response = put()
+    assert response.status_code == 400
+    assert response.json()["username"]
+
+
+@pytest.mark.django_db
+def test_user_details_invalid_locale(user_client, wiki_user):
+    data = {
+        "fullname": wiki_user.fullname,
+        "username": wiki_user.username,
+        "is_newsletter_subscribed": False,
+        # Note! It's not a valid locale
+        "locale": "xxx",
+    }
+    response = user_client.put(
+        reverse("api.v1.user_details"), content_type="application/json", data=data,
+    )
+    assert response.status_code == 400
+    assert response.json()["locale"]
+
+
+@mock.patch("kuma.users.newsletter.tasks.create_or_update_contact.delay")
+@pytest.mark.django_db
+def test_user_details_toggle_is_newsletter_subscribed_on(
+    mock_create_or_update_newsletter_contact_delay, user_client, wiki_user
+):
+    wiki_user.is_newsletter_subscribed = False
+    wiki_user.save()
+    data = {
+        "fullname": wiki_user.fullname,
+        "username": wiki_user.username,
+        "is_newsletter_subscribed": True,  # Note!
+        "locale": wiki_user.locale,
+    }
+    response = user_client.put(
+        reverse("api.v1.user_details"), content_type="application/json", data=data,
+    )
+    assert response.status_code == 200
+    wiki_user.refresh_from_db()
+    assert wiki_user.is_newsletter_subscribed
+    mock_create_or_update_newsletter_contact_delay.assert_called_once_with(wiki_user.pk)
+
+
+@mock.patch("kuma.users.newsletter.tasks.delete_contact.delay")
+@pytest.mark.django_db
+def test_user_details_toggle_is_newsletter_subscribed_off(
+    mock_create_or_update_newsletter_contact_delay, user_client, wiki_user
+):
+    wiki_user.is_newsletter_subscribed = True
+    wiki_user.save()
+    data = {
+        "fullname": wiki_user.fullname,
+        "username": wiki_user.username,
+        "is_newsletter_subscribed": False,  # Note!
+        "locale": wiki_user.locale,
+    }
+    response = user_client.put(
+        reverse("api.v1.user_details"), content_type="application/json", data=data,
+    )
+    assert response.status_code == 200
+    wiki_user.refresh_from_db()
+    assert not wiki_user.is_newsletter_subscribed
+    mock_create_or_update_newsletter_contact_delay.assert_called_once_with(
+        wiki_user.email
+    )
+
+
 @mock.patch("kuma.users.newsletter.utils.check_is_in_sendinblue_list")
 @pytest.mark.django_db
 def test_sendinblue_unsubscribe(mock_check_sendinblue, client):