diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 23b3b1ab1..b06c3e45b 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -37,6 +37,7 @@ from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.otp.api import OTPStageViewSet from passbook.stages.password.api import PasswordStageViewSet from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet +from passbook.stages.user_create.api import UserCreateStageViewSet from passbook.stages.user_login.api import UserLoginStageViewSet LOGGER = get_logger() @@ -85,6 +86,7 @@ router.register("stages/otp", OTPStageViewSet) router.register("stages/password", PasswordStageViewSet) router.register("stages/prompt", PromptStageViewSet) router.register("stages/prompt/prompts", PromptViewSet) +router.register("stages/user_create", UserCreateStageViewSet) router.register("stages/user_login", UserLoginStageViewSet) router.register("flows", FlowViewSet) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index ac8534c3e..ca5a6c087 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -107,6 +107,7 @@ INSTALLED_APPS = [ "passbook.stages.email.apps.PassbookStageEmailConfig", "passbook.stages.prompt.apps.PassbookStagPromptConfig", "passbook.stages.identification.apps.PassbookStageIdentificationConfig", + "passbook.stages.user_create.apps.PassbookStageUserCreateConfig", "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", "passbook.stages.otp.apps.PassbookStageOTPConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig", @@ -357,7 +358,7 @@ TEST_OUTPUT_VERBOSE = 2 TEST_OUTPUT_FILE_NAME = "unittest.xml" if any("test" in arg for arg in sys.argv): - LOGGING = None + # LOGGING = None TEST = True CELERY_TASK_ALWAYS_EAGER = True diff --git a/passbook/stages/user_create/__init__.py b/passbook/stages/user_create/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/user_create/api.py b/passbook/stages/user_create/api.py new file mode 100644 index 000000000..d95415eed --- /dev/null +++ b/passbook/stages/user_create/api.py @@ -0,0 +1,24 @@ +"""User Create Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.stages.user_create.models import UserCreateStage + + +class UserCreateStageSerializer(ModelSerializer): + """UserCreateStage Serializer""" + + class Meta: + + model = UserCreateStage + fields = [ + "pk", + "name", + ] + + +class UserCreateStageViewSet(ModelViewSet): + """UserCreateStage Viewset""" + + queryset = UserCreateStage.objects.all() + serializer_class = UserCreateStageSerializer diff --git a/passbook/stages/user_create/apps.py b/passbook/stages/user_create/apps.py new file mode 100644 index 000000000..27bb3b467 --- /dev/null +++ b/passbook/stages/user_create/apps.py @@ -0,0 +1,10 @@ +"""passbook create stage app config""" +from django.apps import AppConfig + + +class PassbookStageUserCreateConfig(AppConfig): + """passbook create stage config""" + + name = "passbook.stages.user_create" + label = "passbook_stages_user_create" + verbose_name = "passbook Stages.User Create" diff --git a/passbook/stages/user_create/forms.py b/passbook/stages/user_create/forms.py new file mode 100644 index 000000000..edf5bb784 --- /dev/null +++ b/passbook/stages/user_create/forms.py @@ -0,0 +1,16 @@ +"""passbook flows create forms""" +from django import forms + +from passbook.stages.user_create.models import UserCreateStage + + +class UserCreateStageForm(forms.ModelForm): + """Form to create/edit UserCreateStage instances""" + + class Meta: + + model = UserCreateStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/passbook/stages/user_create/migrations/0001_initial.py b/passbook/stages/user_create/migrations/0001_initial.py new file mode 100644 index 000000000..35f3d418f --- /dev/null +++ b/passbook/stages/user_create/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.5 on 2020-05-10 14:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_flows", "0003_auto_20200509_1258"), + ] + + operations = [ + migrations.CreateModel( + name="UserCreateStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="passbook_flows.Stage", + ), + ), + ], + options={ + "verbose_name": "User Create Stage", + "verbose_name_plural": "User Create Stages", + }, + bases=("passbook_flows.stage",), + ), + ] diff --git a/passbook/stages/user_create/migrations/__init__.py b/passbook/stages/user_create/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/user_create/models.py b/passbook/stages/user_create/models.py new file mode 100644 index 000000000..f0b48a97b --- /dev/null +++ b/passbook/stages/user_create/models.py @@ -0,0 +1,19 @@ +"""create stage models""" +from django.utils.translation import gettext_lazy as _ + +from passbook.flows.models import Stage + + +class UserCreateStage(Stage): + """Create stage, create a user from saved data.""" + + type = "passbook.stages.user_create.stage.UserCreateStageView" + form = "passbook.stages.user_create.forms.UserCreateStageForm" + + def __str__(self): + return f"User Create Stage {self.name}" + + class Meta: + + verbose_name = _("User Create Stage") + verbose_name_plural = _("User Create Stages") diff --git a/passbook/stages/user_create/stage.py b/passbook/stages/user_create/stage.py new file mode 100644 index 000000000..122324683 --- /dev/null +++ b/passbook/stages/user_create/stage.py @@ -0,0 +1,39 @@ +"""Create stage logic""" +from django.contrib import messages +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from structlog import get_logger + +from passbook.core.models import User +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import AuthenticationStage +from passbook.lib.utils.reflection import class_to_path +from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +LOGGER = get_logger() + + +class UserCreateStageView(AuthenticationStage): + """Finalise Enrollment flow by creating a user object.""" + + def get(self, request: HttpRequest) -> HttpResponse: + if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: + message = _("No Pending data.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] + user = User.objects.create_user(**data) + # Set created user as pending_user, so this can be chained with user_login + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = class_to_path( + ModelBackend + ) + LOGGER.debug( + "Created user", + user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], + flow_slug=self.executor.flow.slug, + ) + return self.executor.stage_ok() diff --git a/passbook/stages/user_create/tests.py b/passbook/stages/user_create/tests.py new file mode 100644 index 000000000..d0f21ba88 --- /dev/null +++ b/passbook/stages/user_create/tests.py @@ -0,0 +1,73 @@ +"""create tests""" +import string +from random import SystemRandom + +from django.shortcuts import reverse +from django.test import Client, TestCase + +from passbook.core.models import User +from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding +from passbook.flows.planner import FlowPlan +from passbook.flows.views import SESSION_KEY_PLAN +from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT +from passbook.stages.user_create.models import UserCreateStage + + +class TestUserCreateStage(TestCase): + """Create tests""" + + def setUp(self): + super().setUp() + self.client = Client() + + self.password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + self.flow = Flow.objects.create( + name="test-create", + slug="test-create", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserCreateStage.objects.create(name="create") + FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2) + + def test_valid_create(self): + """Test creation of user""" + plan = FlowPlan(stages=[self.stage]) + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "test-user", + "name": "name", + "email": "test@beryju.org", + "password": self.password, + } + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + User.objects.filter( + username=plan.context[PLAN_CONTEXT_PROMPT]["username"] + ).exists() + ) + + def test_without_data(self): + """Test without data results in error""" + plan = FlowPlan(stages=[self.stage]) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("passbook_flows:denied"))