From d33f632203b98c09c8dcf3c2cd0ca86339878515 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 00:11:01 +0200 Subject: [PATCH 1/8] flows: add CancelView to cancel current flow execution --- .../core/templates/login/form_with_user.html | 2 +- passbook/flows/urls.py | 2 + passbook/flows/views.py | 37 ++++++++++++------- .../templates/oidc_provider/authorize.html | 2 +- .../templates/stages/password/backend.html | 2 +- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/passbook/core/templates/login/form_with_user.html b/passbook/core/templates/login/form_with_user.html index a7c2461fe..bed72f78c 100644 --- a/passbook/core/templates/login/form_with_user.html +++ b/passbook/core/templates/login/form_with_user.html @@ -14,7 +14,7 @@ {{ user.username }}
- {% trans 'Not you?' %} + {% trans 'Not you?' %}
diff --git a/passbook/flows/urls.py b/passbook/flows/urls.py index ca83335f7..26b0ebb59 100644 --- a/passbook/flows/urls.py +++ b/passbook/flows/urls.py @@ -3,6 +3,7 @@ from django.urls import path from passbook.flows.models import FlowDesignation from passbook.flows.views import ( + CancelView, FlowExecutorShellView, FlowExecutorView, FlowPermissionDeniedView, @@ -36,6 +37,7 @@ urlpatterns = [ ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), name="default-unenrollment", ), + path("-/cancel/", CancelView.as_view(), name="cancel"), path("b//", FlowExecutorView.as_view(), name="flow-executor"), path( "/", FlowExecutorShellView.as_view(), name="flow-executor-shell" diff --git a/passbook/flows/views.py b/passbook/flows/views.py index c78f1aa38..bbe010392 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -183,6 +183,30 @@ class FlowPermissionDeniedView(PermissionDeniedView): """User could not be authenticated""" +class FlowExecutorShellView(TemplateView): + """Executor Shell view, loads a dummy card with a spinner + that loads the next stage in the background.""" + + template_name = "flows/shell.html" + + def get_context_data(self, **kwargs) -> Dict[str, Any]: + kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) + kwargs["msg_url"] = reverse("passbook_api:messages-list") + self.request.session[SESSION_KEY_GET] = self.request.GET + return kwargs + + +class CancelView(View): + """View which canels the currently active plan""" + + def get(self, request: HttpRequest) -> HttpResponse: + """View which canels the currently active plan""" + if SESSION_KEY_PLAN in request.session: + del request.session[SESSION_KEY_PLAN] + LOGGER.debug("Canceled current plan") + return redirect("passbook_core:overview") + + class ToDefaultFlow(View): """Redirect to default flow matching by designation""" @@ -206,19 +230,6 @@ class ToDefaultFlow(View): ) -class FlowExecutorShellView(TemplateView): - """Executor Shell view, loads a dummy card with a spinner - that loads the next stage in the background.""" - - template_name = "flows/shell.html" - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) - kwargs["msg_url"] = reverse("passbook_api:messages-list") - self.request.session[SESSION_KEY_GET] = self.request.GET - return kwargs - - def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse: """Convert normal HttpResponse into JSON Response""" if isinstance(source, HttpResponseRedirect) or source.status_code == 302: diff --git a/passbook/providers/oidc/templates/oidc_provider/authorize.html b/passbook/providers/oidc/templates/oidc_provider/authorize.html index 9326ee625..aabbb262e 100644 --- a/passbook/providers/oidc/templates/oidc_provider/authorize.html +++ b/passbook/providers/oidc/templates/oidc_provider/authorize.html @@ -38,7 +38,7 @@ {% blocktrans with user=user %} You are logged in as {{ user }}. Not you? {% endblocktrans %} - {% trans 'Logout' %} + {% trans 'Logout' %}

diff --git a/passbook/stages/password/templates/stages/password/backend.html b/passbook/stages/password/templates/stages/password/backend.html index a2b3db483..74547608d 100644 --- a/passbook/stages/password/templates/stages/password/backend.html +++ b/passbook/stages/password/templates/stages/password/backend.html @@ -21,7 +21,7 @@ {{ user.username }}
From dbee714dac786d4dafa59b5274f33cd66262a3fc Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 00:19:06 +0200 Subject: [PATCH 2/8] api: fix consent stage missing from API --- passbook/api/v2/urls.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 8b2ee36f3..0ff9668b7 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -33,6 +33,7 @@ from passbook.sources.oauth.api import OAuthSourceViewSet from passbook.sources.saml.api import SAMLSourceViewSet from passbook.stages.captcha.api import CaptchaStageViewSet from passbook.stages.dummy.api import DummyStageViewSet +from passbook.stages.consent.api import ConsentStageViewSet from passbook.stages.email.api import EmailStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet @@ -85,6 +86,7 @@ router.register("propertymappings/saml", SAMLPropertyMappingViewSet) router.register("stages/all", StageViewSet) router.register("stages/captcha", CaptchaStageViewSet) +router.register("stages/consent", ConsentStageViewSet) router.register("stages/email", EmailStageViewSet) router.register("stages/identification", IdentificationStageViewSet) router.register("stages/invitation", InvitationStageViewSet) From 9743ad33d60a18c69cd75b327833f7438f67f102 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 00:45:04 +0200 Subject: [PATCH 3/8] ci: add snyk --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 883142150..481221f50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,14 @@ jobs: run: sudo pip install -U wheel pipenv && pipenv install --dev - name: Lint with bandit run: pipenv run bandit -r passbook + snyk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} pyright: runs-on: ubuntu-latest steps: From 8e38bc87bcc0a2daba57955ac09a762406de969c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2020 05:16:31 +0000 Subject: [PATCH 4/8] build(deps): bump boto3 from 1.14.12 to 1.14.13 Bumps [boto3](https://github.com/boto/boto3) from 1.14.12 to 1.14.13. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.14.12...1.14.13) Signed-off-by: dependabot-preview[bot] --- Pipfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 20b2277dc..332684f95 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -46,18 +46,18 @@ }, "boto3": { "hashes": [ - "sha256:2616351c98eec18d20a1d64b33355c86cd855ac96219d1b8428c9bfc590bde53", - "sha256:7daad26a008c91dd7b82fde17d246d1fe6e4b3813426689ef8bac9017a277cfb" + "sha256:77d926c16ab2ab2bfe68811f3bc987e7d79c97f3e2adebf9265c587cdb1fc47b", + "sha256:7f558165eaa608a5d0e05227ee820f4b3cc74533a52c9dde7eb488eba091d50d" ], "index": "pypi", - "version": "==1.14.12" + "version": "==1.14.13" }, "botocore": { "hashes": [ - "sha256:45934d880378777cefeca727f369d1f5aebf6b254e9be58e7c77dd0b059338bb", - "sha256:a94e0e2307f1b9fe3a84660842909cd2680b57a9fc9fb0c3a03b0afb2eadbe21" + "sha256:37b65cc48c99b7dd4d5606e56f76ecd88eb7be392ea8a166df734a6b3035301c", + "sha256:5ac9b53a75852fe4282be8741c8136e6948714fef6eff1bd9babb861f8647ba3" ], - "version": "==1.17.12" + "version": "==1.17.13" }, "celery": { "hashes": [ From bf297b85933e2a975953078ac59c88bc5d2ccf04 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 10:23:39 +0200 Subject: [PATCH 5/8] admin: add info about latest version --- .../templates/administration/overview.html | 12 +- passbook/admin/views/overview.py | 20 ++- passbook/api/v2/urls.py | 2 +- swagger.yaml | 141 ++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) diff --git a/passbook/admin/templates/administration/overview.html b/passbook/admin/templates/administration/overview.html index bc586ce7c..1c4cd1a4f 100644 --- a/passbook/admin/templates/administration/overview.html +++ b/passbook/admin/templates/administration/overview.html @@ -120,7 +120,17 @@
- {{ version }} + {% if version >= version_latest %} + + {% blocktrans with version=version %} + {{ version }} (Up-to-date!) + {% endblocktrans %} + {% else %} + + {% blocktrans with version=version latest=version_latest %} + {{ version }} ({{ latest }} is available!) + {% endblocktrans %} + {% endif %}
diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py index 83d0ea009..5c049cf7d 100644 --- a/passbook/admin/views/overview.py +++ b/passbook/admin/views/overview.py @@ -1,7 +1,11 @@ """passbook administration overview""" +from functools import lru_cache + from django.core.cache import cache from django.shortcuts import redirect, reverse from django.views.generic import TemplateView +from packaging.version import Version, parse +from requests import RequestException, get from passbook import __version__ from passbook.admin.mixins import AdminRequiredMixin @@ -12,6 +16,19 @@ from passbook.root.celery import CELERY_APP from passbook.stages.invitation.models import Invitation +@lru_cache +def latest_version() -> Version: + """Get latest release from GitHub, cached""" + try: + data = get( + "https://api.github.com/repos/beryju/passbook/releases/latest" + ).json() + tag_name = data.get("tag_name") + return parse(tag_name.split("/")[1]) + except RequestException: + return parse("0.0.0") + + class AdministrationOverviewView(AdminRequiredMixin, TemplateView): """Overview View""" @@ -33,7 +50,8 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView): kwargs["stage_count"] = len(Stage.objects.all()) kwargs["flow_count"] = len(Flow.objects.all()) kwargs["invitation_count"] = len(Invitation.objects.all()) - kwargs["version"] = __version__ + kwargs["version"] = parse(__version__) + kwargs["version_latest"] = latest_version() kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5)) kwargs["providers_without_application"] = Provider.objects.filter( application=None diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 0ff9668b7..256cdb589 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -32,8 +32,8 @@ from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceView from passbook.sources.oauth.api import OAuthSourceViewSet from passbook.sources.saml.api import SAMLSourceViewSet from passbook.stages.captcha.api import CaptchaStageViewSet -from passbook.stages.dummy.api import DummyStageViewSet from passbook.stages.consent.api import ConsentStageViewSet +from passbook.stages.dummy.api import DummyStageViewSet from passbook.stages.email.api import EmailStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet diff --git a/swagger.yaml b/swagger.yaml index 8a11bd93f..c5833bff9 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -3275,6 +3275,133 @@ paths: required: true type: string format: uuid + /stages/consent/: + get: + operationId: stages_consent_list + description: ConsentStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: limit + in: query + description: Number of results to return per page. + required: false + type: integer + - name: offset + in: query + description: The initial index from which to return the results. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/ConsentStage' + tags: + - stages + post: + operationId: stages_consent_create + description: ConsentStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/ConsentStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/ConsentStage' + tags: + - stages + parameters: [] + /stages/consent/{stage_uuid}/: + get: + operationId: stages_consent_read + description: ConsentStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/ConsentStage' + tags: + - stages + put: + operationId: stages_consent_update + description: ConsentStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/ConsentStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/ConsentStage' + tags: + - stages + patch: + operationId: stages_consent_partial_update + description: ConsentStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/ConsentStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/ConsentStage' + tags: + - stages + delete: + operationId: stages_consent_delete + description: ConsentStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this Consent Stage. + required: true + type: string + format: uuid /stages/dummy/: get: operationId: stages_dummy_list @@ -6052,6 +6179,20 @@ definitions: description: Private key, acquired from https://www.google.com/recaptcha/intro/v3.html type: string minLength: 1 + ConsentStage: + required: + - name + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 DummyStage: required: - name From f0402236469fb4c9ab98c153eb400200f869fd0c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 10:23:54 +0200 Subject: [PATCH 6/8] audit: move events list from admin to audit app --- passbook/admin/urls.py | 3 --- .../administration => audit/templates}/audit/list.html | 0 passbook/audit/urls.py | 9 ++++++++- passbook/{admin/views/audit.py => audit/views.py} | 2 +- passbook/core/templates/base/page.html | 4 ++-- 5 files changed, 11 insertions(+), 7 deletions(-) rename passbook/{admin/templates/administration => audit/templates}/audit/list.html (100%) rename passbook/{admin/views/audit.py => audit/views.py} (87%) diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index c1a7f824a..56a7603a6 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -3,7 +3,6 @@ from django.urls import path from passbook.admin.views import ( applications, - audit, certificate_key_pair, debug, flows, @@ -252,8 +251,6 @@ urlpatterns = [ certificate_key_pair.CertificateKeyPairDeleteView.as_view(), name="certificatekeypair-delete", ), - # Audit Log - path("audit/", audit.EventListView.as_view(), name="audit-log"), # Groups path("groups/", groups.GroupListView.as_view(), name="groups"), # Debug diff --git a/passbook/admin/templates/administration/audit/list.html b/passbook/audit/templates/audit/list.html similarity index 100% rename from passbook/admin/templates/administration/audit/list.html rename to passbook/audit/templates/audit/list.html diff --git a/passbook/audit/urls.py b/passbook/audit/urls.py index f7327b0ac..83be4fe29 100644 --- a/passbook/audit/urls.py +++ b/passbook/audit/urls.py @@ -1,2 +1,9 @@ """passbook audit urls""" -urlpatterns = [] +from django.urls import path + +from passbook.audit.views import EventListView + +urlpatterns = [ + # Audit Log + path("audit/", EventListView.as_view(), name="log"), +] diff --git a/passbook/admin/views/audit.py b/passbook/audit/views.py similarity index 87% rename from passbook/admin/views/audit.py rename to passbook/audit/views.py index 56b274c8f..90599db49 100644 --- a/passbook/admin/views/audit.py +++ b/passbook/audit/views.py @@ -9,7 +9,7 @@ class EventListView(PermissionListMixin, ListView): """Show list of all invitations""" model = Event - template_name = "administration/audit/list.html" + template_name = "audit/list.html" permission_required = "passbook_audit.view_event" ordering = "-created" paginate_by = 20 diff --git a/passbook/core/templates/base/page.html b/passbook/core/templates/base/page.html index f3fea0fd7..9b918451f 100644 --- a/passbook/core/templates/base/page.html +++ b/passbook/core/templates/base/page.html @@ -32,8 +32,8 @@ {% if user.is_superuser %}
  • {% trans 'Administrate' %}
  • -
  • {% trans 'Monitor' %}
  • +
  • {% trans 'Monitor' %}
  • {% endif %} From 34be1dd9f445223925a1848348c63f8b7a408ca6 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 10:55:38 +0200 Subject: [PATCH 7/8] admin: add execute button to flow which executes flow without cache --- .../templates/administration/flow/list.html | 1 + passbook/admin/urls.py | 5 ++++ passbook/admin/views/flows.py | 25 ++++++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/passbook/admin/templates/administration/flow/list.html b/passbook/admin/templates/administration/flow/list.html index c013e9348..0956ead3f 100644 --- a/passbook/admin/templates/administration/flow/list.html +++ b/passbook/admin/templates/administration/flow/list.html @@ -61,6 +61,7 @@ {% trans 'Edit' %} {% trans 'Delete' %} + {% trans 'Execute' %} {% endfor %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index 56a7603a6..af7e41ec9 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -187,6 +187,11 @@ urlpatterns = [ path( "flows//update/", flows.FlowUpdateView.as_view(), name="flow-update", ), + path( + "flows//execute/", + flows.FlowDebugExecuteView.as_view(), + name="flow-execute", + ), path( "flows//delete/", flows.FlowDeleteView.as_view(), name="flow-delete", ), diff --git a/passbook/admin/views/flows.py b/passbook/admin/views/flows.py index 377cf93f4..2ec591c15 100644 --- a/passbook/admin/views/flows.py +++ b/passbook/admin/views/flows.py @@ -5,13 +5,17 @@ from django.contrib.auth.mixins import ( PermissionRequiredMixin as DjangoPermissionRequiredMixin, ) from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpRequest, HttpResponse from django.urls import reverse_lazy from django.utils.translation import ugettext as _ -from django.views.generic import DeleteView, ListView, UpdateView +from django.views.generic import DeleteView, DetailView, ListView, UpdateView from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from passbook.flows.forms import FlowForm from passbook.flows.models import Flow +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.views import SESSION_KEY_PLAN, FlowPlanner +from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.views import CreateAssignPermView @@ -46,6 +50,25 @@ class FlowCreateView( return super().get_context_data(**kwargs) +class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + """Debug exectue flow, setting the current user as pending user""" + + model = Flow + permission_required = "passbook_flows.view_flow" + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, pk: str) -> HttpResponse: + """Debug exectue flow, setting the current user as pending user""" + flow: Flow = self.get_object() + planner = FlowPlanner(flow) + planner.use_cache = False + plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user,},) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "passbook_flows:flow-executor-shell", self.request.GET, flow_slug=flow.slug, + ) + + class FlowUpdateView( SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView ): From de3b753a263c724b904607ae5dd49dd30ecbd3d9 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 30 Jun 2020 11:18:39 +0200 Subject: [PATCH 8/8] flows: show error message in flow when stage raises --- passbook/flows/templates/flows/error.html | 22 ++++++++++++++++ passbook/flows/views.py | 31 +++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 passbook/flows/templates/flows/error.html diff --git a/passbook/flows/templates/flows/error.html b/passbook/flows/templates/flows/error.html new file mode 100644 index 000000000..9f549077c --- /dev/null +++ b/passbook/flows/templates/flows/error.html @@ -0,0 +1,22 @@ +{% load i18n %} + + + + + diff --git a/passbook/flows/views.py b/passbook/flows/views.py index bbe010392..0e6d0fef5 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -1,4 +1,5 @@ """passbook multi-stage authentication engine""" +from traceback import format_tb from typing import Any, Dict, Optional from django.http import ( @@ -8,7 +9,7 @@ from django.http import ( HttpResponseRedirect, JsonResponse, ) -from django.shortcuts import get_object_or_404, redirect, reverse +from django.shortcuts import get_object_or_404, redirect, render, reverse from django.template.response import TemplateResponse from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -106,8 +107,18 @@ class FlowExecutorView(View): stage=self.current_stage, flow_slug=self.flow.slug, ) - stage_response = self.current_stage_view.get(request, *args, **kwargs) - return to_stage_response(request, stage_response) + try: + stage_response = self.current_stage_view.get(request, *args, **kwargs) + return to_stage_response(request, stage_response) + except Exception as exc: # pylint: disable=broad-except + return to_stage_response( + request, + render( + request, + "flows/error.html", + {"error": exc, "tb": "".join(format_tb(exc.__traceback__)),}, + ), + ) def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """pass post request to current stage""" @@ -117,8 +128,18 @@ class FlowExecutorView(View): stage=self.current_stage, flow_slug=self.flow.slug, ) - stage_response = self.current_stage_view.post(request, *args, **kwargs) - return to_stage_response(request, stage_response) + try: + stage_response = self.current_stage_view.post(request, *args, **kwargs) + return to_stage_response(request, stage_response) + except Exception as exc: # pylint: disable=broad-except + return to_stage_response( + request, + render( + request, + "flows/error.html", + {"error": exc, "tb": "".join(format_tb(exc.__traceback__)),}, + ), + ) def _initiate_plan(self) -> FlowPlan: planner = FlowPlanner(self.flow)