diff --git a/Pipfile.lock b/Pipfile.lock index 7cb97bd17..06a8d01b3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "amqp": { "hashes": [ - "sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8", - "sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d" + "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", + "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "version": "==2.5.2" + "version": "==2.6.0" }, "asgiref": { "hashes": [ @@ -53,17 +53,18 @@ }, "boto3": { "hashes": [ - "sha256:c774003dc13d6de74b5e19d2b84d625da4456e64bd97f44baa1fcf40d808d29a" + "sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d", + "sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba" ], "index": "pypi", - "version": "==1.13.19" + "version": "==1.13.20" }, "botocore": { "hashes": [ - "sha256:5cb537e7a4cf2d59a2a8dfbbc8e14ec3bc5b640eb81a1bf3bb0523c0a75e6b1b", - "sha256:7b8b1f082665c8670b9aa70143ee527c5d04939fe027a63ac5958359be20ccb0" + "sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc", + "sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0" ], - "version": "==1.16.19" + "version": "==1.16.20" }, "celery": { "hashes": [ @@ -363,11 +364,11 @@ }, "kombu": { "hashes": [ - "sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76", - "sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2" + "sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505", + "sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07" ], "index": "pypi", - "version": "==4.6.8" + "version": "==4.6.9" }, "ldap3": { "hashes": [ @@ -687,10 +688,10 @@ }, "redis": { "hashes": [ - "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242", - "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251" + "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", + "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], - "version": "==3.5.2" + "version": "==3.5.3" }, "requests": { "hashes": [ @@ -857,10 +858,10 @@ }, "autopep8": { "hashes": [ - "sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954" + "sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0" ], "index": "pypi", - "version": "==1.5.2" + "version": "==1.5.3" }, "bandit": { "hashes": [ @@ -970,10 +971,10 @@ }, "gitpython": { "hashes": [ - "sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94", - "sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09" + "sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a", + "sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac" ], - "version": "==3.1.2" + "version": "==3.1.3" }, "isort": { "hashes": [ diff --git a/passbook/core/models.py b/passbook/core/models.py index 08ad322dc..13517ea0e 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _ from guardian.mixins import GuardianUserMixin from jinja2 import Undefined from jinja2.exceptions import TemplateSyntaxError, UndefinedError -from jinja2.nativetypes import NativeEnvironment from model_utils.managers import InheritanceManager from structlog import get_logger @@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel from passbook.policies.models import PolicyBindingModel LOGGER = get_logger() -NATIVE_ENVIRONMENT = NativeEnvironment() def default_token_duration(): @@ -208,8 +206,11 @@ class PropertyMapping(models.Model): self, user: Optional[User], request: Optional[HttpRequest], **kwargs ) -> Any: """Evaluate `self.expression` using `**kwargs` as Context.""" + from passbook.policies.expression.evaluator import Evaluator + + evaluator = Evaluator() try: - expression = NATIVE_ENVIRONMENT.from_string(self.expression) + expression = evaluator.env.from_string(self.expression) except TemplateSyntaxError as exc: raise PropertyMappingExpressionException from exc try: @@ -221,8 +222,11 @@ class PropertyMapping(models.Model): raise PropertyMappingExpressionException from exc def save(self, *args, **kwargs): + from passbook.policies.expression.evaluator import Evaluator + + evaluator = Evaluator() try: - NATIVE_ENVIRONMENT.from_string(self.expression) + evaluator.env.from_string(self.expression) except TemplateSyntaxError as exc: raise ValidationError("Expression Syntax Error") from exc return super().save(*args, **kwargs) diff --git a/passbook/core/templates/partials/form.html b/passbook/core/templates/partials/form.html index 83a6e8ae3..efd249d08 100644 --- a/passbook/core/templates/partials/form.html +++ b/passbook/core/templates/partials/form.html @@ -2,6 +2,13 @@ {% load i18n %} {% csrf_token %} +{% if form.non_field_errors %} +
+

+ {{ form.non_field_errors }} +

+
+{% endif %} {% for field in form %}
{% if field.field.widget|fieldtype == 'RadioSelect' %} diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py index 25f2bd1a7..df0dc5a7e 100644 --- a/passbook/flows/tests/test_planner.py +++ b/passbook/flows/tests/test_planner.py @@ -1,13 +1,15 @@ """flow planner tests""" from unittest.mock import MagicMock, patch +from django.core.cache import cache from django.shortcuts import reverse from django.test import RequestFactory, TestCase from guardian.shortcuts import get_anonymous_user +from passbook.core.models import User from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlanner +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from passbook.policies.types import PolicyResult from passbook.stages.dummy.models import DummyStage @@ -81,3 +83,24 @@ class TestFlowPlanner(TestCase): self.assertEqual( TIME_NOW_MOCK.call_count, 2 ) # When taking from cache, time is not measured + + def test_planner_default_context(self): + """Test planner with default_context""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + FlowStageBinding.objects.create( + flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0 + ) + + user = User.objects.create(username="test-user") + request = self.request_factory.get( + reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = user + planner = FlowPlanner(flow) + planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user}) + key = cache_key(flow, user) + self.assertTrue(cache.get(key) is not None) diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 37380a90c..f955106db 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -34,7 +34,7 @@ class FlowExecutorView(View): def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) - self.flow = get_object_or_404(Flow, slug=flow_slug) + self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: """When a flow is non-applicable check if user is on the correct domain""" diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index b2120bb74..01ed3b7ae 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -1,22 +1,21 @@ """passbook expression policy evaluator""" import re -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import Any, Dict, List, Optional from django.core.exceptions import ValidationError +from django.http import HttpRequest from jinja2 import Undefined from jinja2.exceptions import TemplateSyntaxError from jinja2.nativetypes import NativeEnvironment from requests import Session from structlog import get_logger +from passbook.core.models import User from passbook.flows.planner import PLAN_CONTEXT_SSO from passbook.flows.views import SESSION_KEY_PLAN from passbook.lib.utils.http import get_client_ip from passbook.policies.types import PolicyRequest, PolicyResult -if TYPE_CHECKING: - from passbook.core.models import User - LOGGER = get_logger() @@ -25,12 +24,33 @@ class Evaluator: _env: NativeEnvironment + _context: Dict[str, Any] + _messages: List[str] + def __init__(self): - self._env = NativeEnvironment() + self._env = NativeEnvironment( + extensions=["jinja2.ext.do"], + trim_blocks=True, + lstrip_blocks=True, + line_statement_prefix=">", + ) # update passbook/policies/expression/templates/policy/expression/form.html # update docs/policies/expression/index.md self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace + self._env.globals["pb_message"] = self.jinja2_func_message + self._context = { + "pb_is_group_member": Evaluator.jinja2_func_is_group_member, + "pb_user_by": Evaluator.jinja2_func_user_by, + "pb_logger": get_logger(), + "requests": Session(), + } + self._messages = [] + + @property + def env(self) -> NativeEnvironment: + """Access to our custom NativeEnvironment""" + return self._env @staticmethod def jinja2_filter_regex_match(value: Any, regex: str) -> bool: @@ -43,56 +63,69 @@ class Evaluator: return re.sub(regex, repl, value) @staticmethod - def jinja2_func_is_group_member(user: "User", group_name: str) -> bool: + def jinja2_func_user_by(**filters) -> Optional[User]: + """Get user by filters""" + users = User.objects.filter(**filters) + if users: + return users.first() + return None + + @staticmethod + def jinja2_func_is_group_member(user: User, group_name: str) -> bool: """Check if `user` is member of group with name `group_name`""" return user.groups.filter(name=group_name).exists() - def _get_expression_context( - self, request: PolicyRequest, **kwargs - ) -> Dict[str, Any]: - """Return dictionary with additional global variables passed to expression""" + def jinja2_func_message(self, message: str): + """Wrapper to append to messages list, which is returned with PolicyResult""" + self._messages.append(message) + + def set_policy_request(self, request: PolicyRequest): + """Update context based on policy request (if http request is given, update that too)""" # update passbook/policies/expression/templates/policy/expression/form.html # update docs/policies/expression/index.md - kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member - kwargs["pb_logger"] = get_logger() - kwargs["requests"] = Session() - kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False) + self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False) + self._context["request"] = request if request.http_request: - kwargs["pb_client_ip"] = ( - get_client_ip(request.http_request) or "255.255.255.255" - ) - if SESSION_KEY_PLAN in request.http_request.session: - kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN] - return kwargs + self.set_http_request(request.http_request) - def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult: - """Parse and evaluate expression. - If the Expression evaluates to a list with 2 items, the first is used as passing bool and - the second as messages. - If the Expression evaluates to a truthy-object, it is used as passing bool.""" + def set_http_request(self, request: HttpRequest): + """Update context based on http request""" + # update passbook/policies/expression/templates/policy/expression/form.html + # update docs/policies/expression/index.md + self._context["pb_client_ip"] = ( + get_client_ip(request.http_request) or "255.255.255.255" + ) + self._context["request"] = request + if SESSION_KEY_PLAN in request.http_request.session: + self._context["pb_flow_plan"] = request.http_request.session[ + SESSION_KEY_PLAN + ] + + def evaluate(self, expression_source: str) -> PolicyResult: + """Parse and evaluate expression. Policy is expected to return a truthy object. + Messages can be added using 'do pb_message()'.""" try: - expression = self._env.from_string(expression_source) + expression = self._env.from_string(expression_source.lstrip().rstrip()) except TemplateSyntaxError as exc: return PolicyResult(False, str(exc)) try: - result: Optional[Any] = expression.render( - request=request, **self._get_expression_context(request) - ) + result: Optional[Any] = expression.render(self._context) + except Exception as exc: # pylint: disable=broad-except + LOGGER.warning("Expression error", exc=exc) + return PolicyResult(False, str(exc)) + else: + policy_result = PolicyResult(False) + policy_result.messages = tuple(self._messages) if isinstance(result, Undefined): LOGGER.warning( "Expression policy returned undefined", src=expression_source, - req=request, + req=self._context, ) - return PolicyResult(False) - if isinstance(result, (list, tuple)) and len(result) == 2: - return PolicyResult(*result) + policy_result.passing = False if result: - return PolicyResult(bool(result)) - return PolicyResult(False) - except Exception as exc: # pylint: disable=broad-except - LOGGER.warning("Expression error", exc=exc) - return PolicyResult(False, str(exc)) + policy_result.passing = bool(result) + return policy_result def validate(self, expression: str): """Validate expression's syntax, raise ValidationError if Syntax is invalid""" @@ -100,4 +133,4 @@ class Evaluator: self._env.from_string(expression) return True except TemplateSyntaxError as exc: - raise ValidationError("Expression Syntax Error") from exc + raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc diff --git a/passbook/policies/expression/models.py b/passbook/policies/expression/models.py index edf9b629b..e43f17560 100644 --- a/passbook/policies/expression/models.py +++ b/passbook/policies/expression/models.py @@ -16,7 +16,9 @@ class ExpressionPolicy(Policy): def passes(self, request: PolicyRequest) -> PolicyResult: """Evaluate and render expression. Returns PolicyResult(false) on error.""" - return Evaluator().evaluate(self.expression, request) + evaluator = Evaluator() + evaluator.set_policy_request(request) + return evaluator.evaluate(self.expression) def save(self, *args, **kwargs): Evaluator().validate(self.expression) diff --git a/passbook/policies/expression/tests/test_evaluator.py b/passbook/policies/expression/tests/test_evaluator.py index ca22e86ec..e39cdfec9 100644 --- a/passbook/policies/expression/tests/test_evaluator.py +++ b/passbook/policies/expression/tests/test_evaluator.py @@ -17,13 +17,15 @@ class TestEvaluator(TestCase): """test simple value expression""" template = "True" evaluator = Evaluator() - self.assertEqual(evaluator.evaluate(template, self.request).passing, True) + evaluator.set_policy_request(self.request) + self.assertEqual(evaluator.evaluate(template).passing, True) def test_messages(self): """test expression with message return""" - template = "False, 'some message'" + template = '{% do pb_message("some message") %}False' evaluator = Evaluator() - result = evaluator.evaluate(template, self.request) + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) self.assertEqual(result.passing, False) self.assertEqual(result.messages, ("some message",)) @@ -31,7 +33,8 @@ class TestEvaluator(TestCase): """test invalid syntax""" template = "{%" evaluator = Evaluator() - result = evaluator.evaluate(template, self.request) + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) self.assertEqual(result.passing, False) self.assertEqual(result.messages, ("tag name expected",)) @@ -39,7 +42,8 @@ class TestEvaluator(TestCase): """test undefined result""" template = "{{ foo.bar }}" evaluator = Evaluator() - result = evaluator.evaluate(template, self.request) + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) self.assertEqual(result.passing, False) self.assertEqual(result.messages, ("'foo' is undefined",)) diff --git a/passbook/policies/types.py b/passbook/policies/types.py index 1fd65e63b..29bf02932 100644 --- a/passbook/policies/types.py +++ b/passbook/policies/types.py @@ -39,4 +39,6 @@ class PolicyResult: self.messages = messages def __str__(self): - return f"" + if self.messages: + return f"PolicyResult passing={self.passing} messages={self.messages}" + return f"PolicyResult passing={self.passing}" diff --git a/passbook/stages/identification/api.py b/passbook/stages/identification/api.py index bd56a0a61..f40c329e3 100644 --- a/passbook/stages/identification/api.py +++ b/passbook/stages/identification/api.py @@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer): "name", "user_fields", "template", + "enrollment_flow", + "recovery_flow", ] diff --git a/passbook/stages/identification/migrations/0002_auto_20200530_2204.py b/passbook/stages/identification/migrations/0002_auto_20200530_2204.py new file mode 100644 index 000000000..8a554f1ee --- /dev/null +++ b/passbook/stages/identification/migrations/0002_auto_20200530_2204.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.6 on 2020-05-30 22:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0002_default_flows"), + ("passbook_stages_identification", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="identificationstage", + name="enrollment_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Optional enrollment flow, which is linked at the bottom of the page.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="passbook_flows.Flow", + ), + ), + migrations.AddField( + model_name="identificationstage", + name="recovery_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Optional enrollment flow, which is linked at the bottom of the page.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="passbook_flows.Flow", + ), + ), + ] diff --git a/passbook/stages/identification/models.py b/passbook/stages/identification/models.py index d43b6b15c..94bf0ec48 100644 --- a/passbook/stages/identification/models.py +++ b/passbook/stages/identification/models.py @@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ -from passbook.flows.models import Stage +from passbook.flows.models import Flow, Stage class UserFields(models.TextChoices): @@ -29,6 +29,29 @@ class IdentificationStage(Stage): ) template = models.TextField(choices=Templates.choices) + enrollment_flow = models.ForeignKey( + Flow, + on_delete=models.SET_DEFAULT, + null=True, + blank=True, + related_name="+", + default=None, + help_text=_( + "Optional enrollment flow, which is linked at the bottom of the page." + ), + ) + recovery_flow = models.ForeignKey( + Flow, + on_delete=models.SET_DEFAULT, + null=True, + blank=True, + related_name="+", + default=None, + help_text=_( + "Optional enrollment flow, which is linked at the bottom of the page." + ), + ) + type = "passbook.stages.identification.stage.IdentificationStageView" form = "passbook.stages.identification.forms.IdentificationStageForm" diff --git a/passbook/stages/identification/stage.py b/passbook/stages/identification/stage.py index c6abe62b6..38189bf1b 100644 --- a/passbook/stages/identification/stage.py +++ b/passbook/stages/identification/stage.py @@ -10,7 +10,6 @@ from django.views.generic import FormView from structlog import get_logger from passbook.core.models import Source, User -from passbook.flows.models import FlowDesignation from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.stage import StageView from passbook.stages.identification.forms import IdentificationForm @@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView): return [current_stage.template] def get_context_data(self, **kwargs): + current_stage: IdentificationStage = self.executor.current_stage # Check for related enrollment and recovery flow, add URL to view - enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT) - if enrollment_flow: + if current_stage.enrollment_flow: kwargs["enroll_url"] = reverse( "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": enrollment_flow.slug}, + kwargs={"flow_slug": current_stage.enrollment_flow.slug}, ) - recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY) - if recovery_flow: + if current_stage.recovery_flow: kwargs["recovery_url"] = reverse( "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": recovery_flow.slug}, + kwargs={"flow_slug": current_stage.recovery_flow.slug}, ) kwargs["primary_action"] = _("Log in") diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/passbook/stages/identification/templates/stages/identification/recovery.html index 1dab0ae77..4c9d07c18 100644 --- a/passbook/stages/identification/templates/stages/identification/recovery.html +++ b/passbook/stages/identification/templates/stages/identification/recovery.html @@ -1,72 +1,29 @@ -{% extends 'base/skeleton.html' %} - -{% load static %} {% load i18n %} +{% load static %} -{% block body %} -
- - - - - - - - - - - + + -{% include 'partials/messages.html' %} - -{% endblock %} + diff --git a/passbook/stages/identification/tests.py b/passbook/stages/identification/tests.py index 4ecf255e9..fa8c3919d 100644 --- a/passbook/stages/identification/tests.py +++ b/passbook/stages/identification/tests.py @@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase): slug="unique-enrollment-string", designation=FlowDesignation.ENROLLMENT, ) + self.stage.enrollment_flow = flow + self.stage.save() FlowStageBinding.objects.create( flow=flow, stage=self.stage, order=0, ) response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.name, response.rendered_content) + self.assertIn(flow.slug, response.rendered_content) def test_recovery_flow(self): """Test that recovery flow is linked correctly""" @@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase): slug="unique-recovery-string", designation=FlowDesignation.RECOVERY, ) + self.stage.recovery_flow = flow + self.stage.save() FlowStageBinding.objects.create( flow=flow, stage=self.stage, order=0, ) response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.name, response.rendered_content) + self.assertIn(flow.slug, response.rendered_content) diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py index 7347e9053..e9157d198 100644 --- a/passbook/stages/prompt/forms.py +++ b/passbook/stages/prompt/forms.py @@ -64,4 +64,4 @@ class PromptForm(forms.Form): engine.build() result = engine.result if not result.passing: - raise forms.ValidationError(result.messages) + raise forms.ValidationError(list(result.messages)) diff --git a/swagger.yaml b/swagger.yaml index d999299c8..ac38448f0 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -5919,6 +5919,20 @@ definitions: enum: - stages/identification/login.html - stages/identification/recovery.html + enrollment_flow: + title: Enrollment flow + description: Optional enrollment flow, which is linked at the bottom of the + page. + type: string + format: uuid + x-nullable: true + recovery_flow: + title: Recovery flow + description: Optional enrollment flow, which is linked at the bottom of the + page. + type: string + format: uuid + x-nullable: true InvitationStage: required: - name