Skip to content

Commit

Permalink
Merge pull request #9363 from rtibbles/public_signup_offering
Browse files Browse the repository at this point in the history
Public signup viewset
  • Loading branch information
rtibbles authored May 24, 2022
2 parents c343932 + b586978 commit 7136d74
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 92 deletions.
120 changes: 52 additions & 68 deletions kolibri/core/auth/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -249,7 +247,6 @@ class Meta:

class PublicFacilityUserViewSet(ReadOnlyValuesViewset):
queryset = FacilityUser.objects.all()
serializer_class = PublicFacilityUserSerializer
authentication_classes = [BasicMultiArgumentAuthentication]
permission_classes = [IsAuthenticated]
values = (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
37 changes: 24 additions & 13 deletions kolibri/core/auth/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
43 changes: 32 additions & 11 deletions kolibri/core/auth/test/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import base64
import collections
import sys
import uuid
from importlib import import_module

import factory
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -964,18 +960,24 @@ 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}
)
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(
Expand Down Expand 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):
Expand Down
2 changes: 2 additions & 0 deletions kolibri/core/public/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down

0 comments on commit 7136d74

Please sign in to comment.