Merge branch 'master' into docs-flows
This commit is contained in:
commit
23193314f1
39
Pipfile.lock
generated
39
Pipfile.lock
generated
|
@ -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": [
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -2,6 +2,13 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="pf-c-form__group has-error">
|
||||
<p class="pf-c-form__helper-text pf-m-error">
|
||||
{{ form.non_field_errors }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for field in form %}
|
||||
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",))
|
||||
|
||||
|
|
|
@ -39,4 +39,6 @@ class PolicyResult:
|
|||
self.messages = messages
|
||||
|
||||
def __str__(self):
|
||||
return f"<PolicyResult passing={self.passing}>"
|
||||
if self.messages:
|
||||
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||
return f"PolicyResult passing={self.passing}"
|
||||
|
|
|
@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
|
|||
"name",
|
||||
"user_fields",
|
||||
"template",
|
||||
"enrollment_flow",
|
||||
"recovery_flow",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -1,72 +1,29 @@
|
|||
{% extends 'base/skeleton.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Trouble Logging In?' %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% block above_form %}
|
||||
{% endblock %}
|
||||
|
||||
{% include 'partials/form.html' %}
|
||||
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% include 'partials/messages.html' %}
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
|
||||
alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
|
||||
alt="passbook branding" />
|
||||
</header>
|
||||
<main class="pf-c-login__main">
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Trouble Logging In?' %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% block above_form %}
|
||||
{% endblock %}
|
||||
|
||||
{% include 'partials/form.html' %}
|
||||
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
{% if config.login.subtext %}
|
||||
<p>{{ config.login.subtext }}</p>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
|
||||
</li>
|
||||
{% config 'passbook.footer_links' as footer_links %}
|
||||
{% for link in footer_links %}
|
||||
<li>
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
<footer class="pf-c-login__main-footer">
|
||||
{% if config.login.subtext %}
|
||||
<p>{{ config.login.subtext }}</p>
|
||||
{% endif %}
|
||||
</footer>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
14
swagger.yaml
14
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
|
||||
|
|
Reference in a new issue