Repository: learningequality/kolibri

Issue:
Create public facility signup viewset
This should just be a public alias for the current SignUpViewset in the auth app. Need to add documentation to that viewset to clarify that it is now being used as a public interface, and so if it is updated for internal uses, updates must now be versioned.

We can either version the public viewset with a prefix, an optional version parameter to the signup payload, or with a 'Cinderella' strategy, where we try to use each possible version for signing up until one fits.

The latter seems simplest to implement, and reflects a strategy that we have already used for our content database schema matching.


Discussion:

Resolving patch:
diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py
--- a/kolibri/core/auth/api.py
+++ b/kolibri/core/auth/api.py
@@ -15,7 +15,6 @@
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import PermissionDenied
-from django.db import transaction
 from django.db.models import Func
 from django.db.models import OuterRef
 from django.db.models import Q
@@ -24,9 +23,9 @@
 from django.db.models import Value
 from django.db.models.functions import Cast
 from django.http import Http404
-from django.http import HttpResponseForbidden
 from django.utils.decorators import method_decorator
 from django.utils.timezone import now
+from django.views.decorators.csrf import csrf_exempt
 from django.views.decorators.csrf import ensure_csrf_cookie
 from django_filters.rest_framework import CharFilter
 from django_filters.rest_framework import ChoiceFilter
@@ -41,9 +40,9 @@
 from rest_framework import status
 from rest_framework import views
 from rest_framework import viewsets
+from rest_framework.mixins import CreateModelMixin
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.serializers import ValidationError
 
 from .constants import collection_kinds
 from .constants import role_kinds
@@ -62,7 +61,6 @@
 from .serializers import LearnerGroupSerializer
 from .serializers import MembershipSerializer
 from .serializers import PublicFacilitySerializer
-from .serializers import PublicFacilityUserSerializer
 from .serializers import RoleSerializer
 from kolibri.core import error_constants
 from kolibri.core.api import ReadOnlyValuesViewset
@@ -249,7 +247,6 @@ class Meta:
 
 class PublicFacilityUserViewSet(ReadOnlyValuesViewset):
     queryset = FacilityUser.objects.all()
-    serializer_class = PublicFacilityUserSerializer
     authentication_classes = [BasicMultiArgumentAuthentication]
     permission_classes = [IsAuthenticated]
     values = (
@@ -343,25 +340,12 @@ def consolidate(self, items, queryset):
             output.append(item)
         return output
 
-    def set_password_if_needed(self, instance, serializer):
-        with transaction.atomic():
-            if serializer.validated_data.get("password", ""):
-                if serializer.validated_data.get("password", "") != "NOT_SPECIFIED":
-                    instance.set_password(serializer.validated_data["password"])
-                instance.save()
-        return instance
-
     def perform_update(self, serializer):
         instance = serializer.save()
-        self.set_password_if_needed(instance, serializer)
         # if the user is updating their own password, ensure they don't get logged out
         if self.request.user == instance:
             update_session_auth_hash(self.request, instance)
 
-    def perform_create(self, serializer):
-        instance = serializer.save()
-        self.set_password_if_needed(instance, serializer)
-
 
 class ExistingUsernameView(views.APIView):
     def get(self, request):
@@ -642,64 +626,64 @@ def annotate_queryset(self, queryset):
         return annotate_array_aggregate(queryset, user_ids="membership__user__id")
 
 
-class SignUpViewSet(viewsets.ViewSet):
+class SignUpViewSet(viewsets.GenericViewSet, CreateModelMixin):
 
     serializer_class = FacilityUserSerializer
 
-    def extract_request_data(self, request):
-        return {
-            "username": request.data.get("username", ""),
-            "full_name": request.data.get("full_name", ""),
-            "password": request.data.get("password", ""),
-            "facility": request.data.get("facility"),
-            "gender": request.data.get("gender", ""),
-            "birth_year": request.data.get("birth_year", ""),
-        }
+    def check_can_signup(self, serializer):
+        if not serializer.validated_data["facility"].dataset.learner_can_sign_up:
+            raise PermissionDenied("Cannot sign up to this facility")
 
-    def create(self, request):
+    def perform_create(self, serializer):
+        self.check_can_signup(serializer)
+        serializer.save()
+        data = serializer.validated_data
+        authenticated_user = authenticate(
+            username=data["username"],
+            password=data["password"],
+            facility=data["facility"],
+        )
+        login(self.request, authenticated_user)
 
-        data = self.extract_request_data(request)
-        facility_id = data["facility"]
 
-        if facility_id is None:
-            facility = Facility.get_default_facility()
-            data["facility"] = facility.id
-        else:
+@method_decorator(csrf_exempt, name="dispatch")
+class PublicSignUpViewSet(SignUpViewSet):
+    """
+    Identical to the SignUpViewset except that it does not login the user.
+    This endpoint is intended to allow a FacilityUser in a different facility
+    on another device to be cloned into a facility on this device, to facilitate
+    moving a user from one facility to another.
+
+    It also allows for historic serializer classes in the case that we
+    make an update to our implementation, and we want to keep the API stable.
+    """
+
+    legacy_serializer_classes = []
+
+    def create(self, request, *args, **kwargs):
+        exception = None
+        serializer_kwargs = dict(data=request.data)
+        serializer_kwargs.setdefault("context", self.get_serializer_context())
+        for serializer_class in [
+            self.serializer_class
+        ] + self.legacy_serializer_classes:
+            serializer = serializer_class(**serializer_kwargs)
             try:
-                facility = Facility.objects.select_related("dataset").get(
-                    id=facility_id
-                )
-            except Facility.DoesNotExist:
-                raise ValidationError(
-                    "Facility does not exist.",
-                    code=error_constants.FACILITY_DOES_NOT_EXIST,
-                )
+                serializer.is_valid(raise_exception=True)
+                break
+            except Exception as e:
+                exception = e
+        if exception:
+            raise exception
+        self.perform_create(serializer)
+        headers = self.get_success_headers(serializer.data)
+        return Response(
+            serializer.data, status=status.HTTP_201_CREATED, headers=headers
+        )
 
-        if not facility.dataset.learner_can_sign_up:
-            return HttpResponseForbidden("Cannot sign up to this facility")
-
-        # we validate the user's input, and if valid, login as user
-        serialized_user = self.serializer_class(data=data)
-        if serialized_user.is_valid(raise_exception=True):
-            if (
-                data["password"] == "NOT_SPECIFIED"
-                and not facility.dataset.learner_can_login_with_no_password
-            ):
-                raise ValidationError(
-                    "No password specified and it is required",
-                    code=error_constants.PASSWORD_NOT_SPECIFIED,
-                )
-            serialized_user.save()
-            if data["password"] != "NOT_SPECIFIED":
-                serialized_user.instance.set_password(data["password"])
-                serialized_user.instance.save()
-            authenticated_user = authenticate(
-                username=data["username"],
-                password=data["password"],
-                facility=data["facility"],
-            )
-            login(request, authenticated_user)
-            return Response(serialized_user.data, status=status.HTTP_201_CREATED)
+    def perform_create(self, serializer):
+        self.check_can_signup(serializer)
+        serializer.save()
 
 
 class SetNonSpecifiedPasswordView(views.APIView):
diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py
--- a/kolibri/core/auth/serializers.py
+++ b/kolibri/core/auth/serializers.py
@@ -26,6 +26,12 @@ class Meta:
 
 class FacilityUserSerializer(serializers.ModelSerializer):
     roles = RoleSerializer(many=True, read_only=True)
+    facility = serializers.PrimaryKeyRelatedField(
+        queryset=Facility.objects.all(),
+        default=Facility.get_default_facility,
+        required=False,
+        error_messages={"does_not_exist": "Facility does not exist."},
+    )
 
     class Meta:
         model = FacilityUser
@@ -44,10 +50,28 @@ class Meta:
         )
         read_only_fields = ("is_superuser",)
 
+    def save(self, **kwargs):
+        instance = super(FacilityUserSerializer, self).save(**kwargs)
+        validated_data = dict(list(self.validated_data.items()) + list(kwargs.items()))
+        password = validated_data.get("password")
+        if password and password != "NOT_SPECIFIED":
+            instance.set_password(password)
+            instance.save()
+        return instance
+
     def validate(self, attrs):
         username = attrs.get("username")
         # first condition is for creating object, second is for updating
         facility = attrs.get("facility") or getattr(self.instance, "facility")
+        if (
+            "password" in attrs
+            and attrs["password"] == "NOT_SPECIFIED"
+            and not facility.dataset.learner_can_login_with_no_password
+        ):
+            raise serializers.ValidationError(
+                "No password specified and it is required",
+                code=error_constants.PASSWORD_NOT_SPECIFIED,
+            )
         # if obj doesn't exist, return data
         try:
             obj = FacilityUser.objects.get(username__iexact=username, facility=facility)
@@ -118,19 +142,6 @@ class Meta:
         fields = ("id", "dataset", "name", "learner_can_login_with_no_password")
 
 
-class PublicFacilityUserSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = FacilityUser
-        fields = (
-            "id",
-            "username",
-            "full_name",
-            "facility",
-            "roles",
-            "is_superuser",
-        )
-
-
 class ClassroomSerializer(serializers.ModelSerializer):
     class Meta:
         model = Classroom
diff --git a/kolibri/core/public/api_urls.py b/kolibri/core/public/api_urls.py
--- a/kolibri/core/public/api_urls.py
+++ b/kolibri/core/public/api_urls.py
@@ -21,6 +21,7 @@
 
 from ..auth.api import PublicFacilityUserViewSet
 from ..auth.api import PublicFacilityViewSet
+from ..auth.api import PublicSignUpViewSet
 from .api import get_public_channel_list
 from .api import get_public_channel_lookup
 from .api import get_public_file_checksums
@@ -31,6 +32,7 @@
 
 router.register(r"v1/facility", PublicFacilityViewSet, base_name="publicfacility")
 router.register(r"facilityuser", PublicFacilityUserViewSet, base_name="publicuser")
+router.register(r"signup", PublicSignUpViewSet, base_name="publicsignup")
 router.register(r"info", InfoViewSet, base_name="info")
 router.register(r"syncqueue", SyncQueueViewSet, base_name="syncqueue")
 


Test patch:
diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py
--- a/kolibri/core/auth/test/test_api.py
+++ b/kolibri/core/auth/test/test_api.py
@@ -5,6 +5,7 @@
 import base64
 import collections
 import sys
+import uuid
 from importlib import import_module
 
 import factory
@@ -899,17 +900,12 @@ def test_session_update_last_active(self):
         self.assertTrue(expire_date < new_expire_date)
 
 
-class AnonSignUpTestCase(APITestCase):
+class SignUpBase(object):
     @classmethod
     def setUpTestData(cls):
         cls.facility = FacilityFactory.create()
         provision_device()
 
-    def post_to_sign_up(self, data):
-        return self.client.post(
-            reverse("kolibri:core:signup-list"), data=data, format="json"
-        )
-
     def test_anon_sign_up_creates_user(self):
         response = self.post_to_sign_up(
             {"username": "user", "password": DUMMY_PASSWORD}
@@ -964,6 +960,17 @@ def test_create_user_for_specific_facility(self):
         )
         self.assertTrue(other_facility.get_members().filter(id=user_id).exists())
 
+    def test_create_user_for_nonexistent_facility(self):
+        response = self.post_to_sign_up(
+            {
+                "username": "bob",
+                "password": DUMMY_PASSWORD,
+                "facility": uuid.uuid4().hex,
+            }
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(models.FacilityUser.objects.all())
+
     def test_create_bad_username_fails(self):
         response = self.post_to_sign_up(
             {"username": "(***)", "password": DUMMY_PASSWORD}
@@ -971,11 +978,6 @@ def test_create_bad_username_fails(self):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertFalse(models.FacilityUser.objects.all())
 
-    def test_sign_up_also_logs_in_user(self):
-        session_key = self.client.session.session_key
-        self.post_to_sign_up({"username": "user", "password": DUMMY_PASSWORD})
-        self.assertNotEqual(session_key, self.client.session.session_key)
-
     def test_sign_up_able_no_guest_access(self):
         set_device_settings(allow_guest_access=False)
         response = self.post_to_sign_up(
@@ -1015,6 +1017,25 @@ def test_password_not_specified_password_not_required(self):
         self.assertTrue(models.FacilityUser.objects.all())
 
 
+class AnonSignUpTestCase(SignUpBase, APITestCase):
+    def post_to_sign_up(self, data):
+        return self.client.post(
+            reverse("kolibri:core:signup-list"), data=data, format="json"
+        )
+
+    def test_sign_up_also_logs_in_user(self):
+        session_key = self.client.session.session_key
+        self.post_to_sign_up({"username": "user", "password": DUMMY_PASSWORD})
+        self.assertNotEqual(session_key, self.client.session.session_key)
+
+
+class PublicSignUpTestCase(SignUpBase, APITestCase):
+    def post_to_sign_up(self, data):
+        return self.client.post(
+            reverse("kolibri:core:publicsignup-list"), data=data, format="json"
+        )
+
+
 class FacilityDatasetAPITestCase(APITestCase):
     @classmethod
     def setUpTestData(cls):