diff --git a/back/admin/integrations/builder_forms.py b/back/admin/integrations/builder_forms.py new file mode 100644 index 000000000..bafdbb76b --- /dev/null +++ b/back/admin/integrations/builder_forms.py @@ -0,0 +1,503 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Div, Field, Layout +from django import forms +from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ + +from admin.integrations.utils import ( + convert_array_to_object, + prepare_initial_data, +) +from admin.integrations.validators import ( + validate_continue_if, + validate_ID, + validate_polling, + validate_status_code, +) +from admin.templates.forms import FieldWithExtraContext + + +class JSONToDict(forms.JSONField): + def clean(self, value): + value = super().clean(value) + if isinstance(value, list): + value = convert_array_to_object(value) + return value + + +class ValueKeyArrayField(FieldWithExtraContext): + template = "value_key_array_field.html" + + +class IntegerListField(FieldWithExtraContext): + template = "manifest_test/integer_list_field.html" + + +class ManifestFormForm(forms.Form): + id = forms.CharField( + label="ID", + help_text=_( + "This value can be used in the other calls. Please do not use spaces or weird characters. A single word in capitals is prefered." + ), + validators=[validate_ID], + ) + name = forms.CharField( + label="Name", help_text=_("The form label shown to the admin") + ) + type = forms.ChoiceField( + choices=(("choice", "choice"), ("input", "input")), + label="Type", + help_text=_( + "If you choose choice, you will be able to set the options yourself OR fetch from an external url." + ), + ) + options_source = forms.ChoiceField( + choices=(("fixed list", "fixed list"), ("fetch url", "fetch url")), + initial="fixed list", + ) + + # fixed items + items = forms.JSONField( + initial=list, + help_text=_( + "Use only if you set type to 'choice'. This is for fixed items (if you don't want to fetch from a URL)" + ), + required=False, + ) + + # dynamic choices + url = forms.URLField( + max_length=255, + help_text=_("The url it should fetch the options from."), + required=False, + ) + method = forms.ChoiceField( + choices=( + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ), + initial="GET", + label=_("Request method"), + ) + data = forms.JSONField(initial=dict, required=False) + cast_data_to_json = forms.BooleanField( + initial=True, + help_text=_( + "Check this if the data should be send as json. When unchecked, it's send as a string." + ), + required=False, + ) + headers = JSONToDict( + initial=list, + help_text=_("(optionally) This will overwrite the default headers."), + required=False, + ) + data_from = forms.CharField( + max_length=255, + initial="", + help_text=_( + "The property it should use from the response of the url if you need to go deeper into the result." + ), + required=False, + ) + choice_value = forms.CharField( + max_length=255, + initial="id", + help_text=_( + "The value it should take for using in other parts of the integration" + ), + required=False, + ) + choice_name = forms.CharField( + max_length=255, + initial="name", + help_text=_("The name that should be displayed to the admin as an option."), + required=False, + ) + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.initial = prepare_initial_data(self.initial) + + if disabled: + for field in self.fields: + self.fields[field].disabled = True + + show_manual_items = "d-none" + show_fetch_url = "" + show_choice_options = "" + if self.initial.get("options_source", "fixed list") == "fixed list": + show_manual_items = "" + show_fetch_url = "d-none" + + if self.initial.get("type", "") == "input": + show_choice_options = "d-none" + + self.helper.layout = Layout( + Div( + Div(Field("id"), css_class="col-6"), + Div(Field("name"), css_class="col-6"), + css_class="row", + ), + Div( + Div(Field("type"), css_class="col-6"), + Div(Field("options_source"), css_class=f"col-6 {show_choice_options}"), + css_class="row", + ), + Div( + ValueKeyArrayField("items", extra_context={"disabled": disabled}), + css_class=f"manual_items {show_manual_items} {show_choice_options}", + ), + Div( + Div( + Div(Field("method"), css_class="col-3"), + Div(Field("url"), css_class="col-9"), + css_class="row", + ), + Div(Field("data_from")), + Div(Field("data")), + Div(Field("cast_data_to_json")), + Div( + Div(Field("choice_value"), css_class="col-6"), + Div(Field("choice_name"), css_class="col-6"), + css_class="row", + ), + ValueKeyArrayField("headers", extra_context={"disabled": disabled}), + css_class=f"fetch_items {show_fetch_url} {show_choice_options}", + ), + ) + + +class ManifestRevokeForm(forms.Form): + url = forms.URLField( + max_length=255, + help_text=_("The url it should fetch the options from."), + required=False, + ) + method = forms.ChoiceField( + choices=( + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ), + initial="GET", + label=_("Request method"), + required=False, + ) + data = forms.JSONField(initial=dict, required=False) + cast_data_to_json = forms.BooleanField( + initial=True, + help_text=_( + "Check this if the data should be send as json. When unchecked, it's send as a string." + ), + required=False, + ) + expected = forms.CharField(initial="", required=False) + status_code = SimpleArrayField( + forms.CharField(max_length=1000), required=False, initial=list + ) + headers = JSONToDict( + initial=list, + help_text=_("(optionally) This will overwrite the default headers."), + required=False, + ) + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.initial = prepare_initial_data(self.initial) + + if disabled: + for field in self.fields: + self.fields[field].disabled = True + + self.helper.layout = Layout( + Div( + Div( + Div(Field("method"), css_class="col-3"), + Div(Field("url"), css_class="col-9"), + css_class="row", + ), + Div(Field("data")), + Div(Field("cast_data_to_json")), + Div(Field("expected")), + IntegerListField("status_code", extra_context={"disabled": disabled}), + ValueKeyArrayField("headers", extra_context={"disabled": disabled}), + ) + ) + + +class ManifestHeadersForm(forms.Form): + headers = forms.JSONField( + initial=list, + help_text=_("(optionally) This will overwrite the default headers."), + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + + self.helper.layout = Layout(ValueKeyArrayField("headers")) + + +class ManifestOauthForm(forms.Form): + oauth = forms.JSONField( + initial=list, help_text=_("OAuth settings"), required=False, label="OAuth" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + + +class ManifestExistsForm(forms.Form): + url = forms.URLField( + max_length=255, help_text=_("The url it should check"), required=False + ) + method = forms.ChoiceField( + choices=( + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ), + initial="GET", + label=_("Request method"), + required=False, + ) + expected = forms.CharField(initial="", required=False) + status_code = SimpleArrayField( + forms.CharField(max_length=1000), + required=False, + initial=[], + validators=[validate_status_code], + ) + headers = forms.JSONField( + initial=list, + help_text=_("(optionally) This will overwrite the default headers."), + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.initial = prepare_initial_data(self.initial) + + self.helper.layout = Layout( + Div( + Div(Field("method"), css_class="col-3"), + Div(Field("url"), css_class="col-9"), + css_class="row", + ), + Div( + Field("expected"), + ), + Div( + IntegerListField("status_code"), + ), + ValueKeyArrayField("headers"), + ) + + +class ManifestInitialDataForm(forms.Form): + id = forms.CharField( + max_length=100, + help_text=_( + "This value can be used in the other calls. Please do not use spaces or weird characters. A single word in capitals is prefered." + ), + validators=[validate_ID], + ) + name = forms.CharField( + max_length=255, + help_text=_( + "Type 'generate' if you want this value to be generated on the fly (different for each execution), will not need to be filled by a user" + ), + ) + description = forms.CharField( + max_length=1255, + help_text=_("This will be shown under the input field for extra context"), + required=False, + ) + secret = forms.BooleanField( + initial=False, + help_text="Enable this if the value should always be masked", + required=False, + ) + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + + if disabled: + for field in self.fields: + self.fields[field].disabled = True + + self.helper.layout = Layout( + Div( + Div(Field("id"), css_class="col-9"), + Div(Field("secret"), css_class="col-3"), + css_class="row", + ), + Div( + Field("name"), + ), + Div( + Field("description"), + ), + ) + + +class ManifestUserInfoForm(forms.Form): + id = forms.CharField( + max_length=100, + help_text=_( + "This value can be used in the other calls. Please do not use spaces or weird characters. A single word in capitals is prefered." + ), + validators=[validate_ID], + ) + name = forms.CharField(max_length=255) + description = forms.CharField( + max_length=1255, + help_text=_("This will be shown under the input field for extra context"), + ) + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + + if disabled: + for field in self.fields: + self.fields[field].disabled = True + + self.helper.layout = Layout( + Div( + Field("id"), + ), + Div( + Field("name"), + ), + Div( + Field("description"), + ), + ) + + +class ManifestExecuteForm(forms.Form): + url = forms.URLField( + max_length=255, help_text=_("The url it should trigger"), required=False + ) + method = forms.ChoiceField( + choices=( + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ), + initial="GET", + label=_("Request method"), + required=False, + ) + status_code = SimpleArrayField( + forms.CharField(max_length=1000), required=False, initial=[] + ) + cast_data_to_json = forms.BooleanField( + initial=True, + help_text=_( + "Check this if the data should be send as json. When unchecked, it's send as a string." + ), + required=False, + ) + headers = forms.JSONField( + initial=list, + help_text=_("(optionally) This will overwrite the default headers."), + required=False, + ) + data = forms.JSONField(initial=dict, required=False) + store_data = forms.JSONField( + initial=dict, + help_text=_( + "(optionally) if you want to store data that's the request returns, then you can do that here." + ), + required=False, + ) + continue_if = forms.JSONField( + initial=dict, + help_text=_("(optionally) set up a condition to block any further requests"), + required=False, + validators=[validate_continue_if], + ) + polling = forms.JSONField( + initial=dict, + help_text=_( + "(optionally) rerun this request a specific amount of times until it passes" + ), + required=False, + validators=[validate_polling], + ) + save_as_file = forms.CharField( + initial="", + help_text=_( + "(optionally) if this request returns a file, then you can save it to use later" + ), + required=False, + ) + files = forms.JSONField( + initial=dict, + help_text=_( + "(optionally) if you want to use any of the previous files to submit" + ), + required=False, + ) + + class Meta: + fields = ( + "url", + "method", + "data", + "headers", + "store_data", + "continue_if", + "polling", + "save_as_file", + "files", + ) + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.initial = prepare_initial_data(self.initial) + + if disabled: + for field in self.fields: + self.fields[field].disabled = True + + self.helper.layout = Layout( + Div( + Div( + Div(Field("method"), css_class="col-3"), + Div(Field("url"), css_class="col-9"), + css_class="row", + ), + Div(Field("data")), + Div(Field("cast_data_to_json")), + Div(Field("store_data")), + Div(Field("continue_if")), + Div(Field("polling")), + Div(Field("save_as_file")), + Div(Field("files")), + ValueKeyArrayField("headers", extra_context={"disabled": disabled}), + ) + ) diff --git a/back/admin/integrations/builder_views.py b/back/admin/integrations/builder_views.py new file mode 100644 index 000000000..197cdabab --- /dev/null +++ b/back/admin/integrations/builder_views.py @@ -0,0 +1,550 @@ +import json + +from django.contrib.auth import get_user_model +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic import View +from django.views.generic.detail import DetailView, SingleObjectMixin +from django.views.generic.edit import CreateView, FormView + +from admin.integrations.models import Integration, IntegrationTracker +from users.mixins import AdminPermMixin, LoginRequiredMixin +from users.models import User + +from .builder_forms import ( + ManifestExecuteForm, + ManifestExistsForm, + ManifestFormForm, + ManifestHeadersForm, + ManifestInitialDataForm, + ManifestOauthForm, + ManifestRevokeForm, + ManifestUserInfoForm, +) +from .utils import convert_array_to_object, convert_object_to_array + + +class SingleObjectMixinWithObj(SingleObjectMixin): + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + return super().dispatch(request, *args, **kwargs) + + +class RedirectToSelfMixin: + def get_success_url(self): + return self.request.path + + +class IntegrationBuilderCreateView(LoginRequiredMixin, AdminPermMixin, CreateView): + template_name = "token_create.html" + model = Integration + fields = ["name", "manifest_type"] + + def form_valid(self, form): + form.instance.integration = Integration.Type.CUSTOM + form.instance.is_active = False + form.instance.manifest = {"execute": []} + obj = form.save() + return HttpResponseRedirect( + reverse("integrations:builder-detail", args=[obj.id]) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = _("Create new test integration") + context["subtitle"] = _("integrations") + return context + + +class IntegrationBuilderView(LoginRequiredMixin, AdminPermMixin, DetailView): + template_name = "manifest_test.html" + model = Integration + context_object_name = "integration" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + manifest = self.object.manifest + for form in manifest.get("form", []): + options_source = form.get("options_source", None) + if options_source is None: + if form.get("url", "") == "": + form["options_source"] = "fixed list" + else: + form["options_source"] = "fetch url" + self.object.manifest = manifest + self.object.save() + + context["form"] = ManifestFormForm() + context["users"] = User.objects.all() + context["title"] = _("Integration builder") + context["subtitle"] = _("integrations") + context["existing_form_items"] = [ + (ManifestFormForm(initial=form, disabled=True), idx) + for idx, form in enumerate(self.object.manifest.get("form", [])) + ] + return context + + +class IntegrationBuilderFormCreateView( + LoginRequiredMixin, + AdminPermMixin, + SingleObjectMixinWithObj, + RedirectToSelfMixin, + FormView, +): + template_name = "manifest_test/form.html" + model = Integration + context_object_name = "integration" + form_class = ManifestFormForm + + def form_valid(self, form): + manifest = self.object.manifest + if "form" not in manifest: + manifest["form"] = [] + manifest["form"].append(form.cleaned_data) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["existing_form_items"] = [ + (ManifestFormForm(initial=form, disabled=True), idx) + for idx, form in enumerate(self.object.manifest.get("form", [])) + ] + return context + + +class IntegrationBuilderFormUpdateView( + LoginRequiredMixin, + AdminPermMixin, + SingleObjectMixinWithObj, + RedirectToSelfMixin, + FormView, +): + template_name = "manifest_test/_update_form.html" + model = Integration + context_object_name = "integration" + form_class = ManifestFormForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = self.object.manifest["form"][self.kwargs["index"]] + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + data = form.cleaned_data + if len(data.get("items", [])): + data["choice_value"] = "key" + data["choice_name"] = "value" + + if form.cleaned_data.get("options_source") == "fixed list": + form.cleaned_data["url"] = "" + + manifest["form"][self.kwargs["index"]] = form.cleaned_data + self.object.manifest = manifest + self.object.save() + form = self.form_class( + initial=self.object.manifest["form"][self.kwargs["index"]], disabled=True + ) + return render( + self.request, + self.template_name, + {"form": form, "index": self.kwargs["index"], "integration": self.object}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["index"] = self.kwargs["index"] + return context + + +class IntegrationBuilderFormDeleteView(LoginRequiredMixin, AdminPermMixin, View): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + manifest = integration.manifest + del manifest["form"][self.kwargs["index"]] + integration.manifest = manifest + integration.save() + return HttpResponse() + + +class IntegrationBuilderHeadersUpdateView( + LoginRequiredMixin, + AdminPermMixin, + SingleObjectMixinWithObj, + SuccessMessageMixin, + RedirectToSelfMixin, + FormView, +): + template_name = "manifest_test/headers.html" + form_class = ManifestHeadersForm + model = Integration + success_message = _("Headers have been updated") + context_object_name = "integration" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = { + "headers": convert_object_to_array(self.object.manifest.get("headers", {})) + } + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + manifest["headers"] = convert_array_to_object(form.cleaned_data["headers"]) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + +class IntegrationBuilderExistsUpdateView( + LoginRequiredMixin, + AdminPermMixin, + SingleObjectMixinWithObj, + SuccessMessageMixin, + RedirectToSelfMixin, + FormView, +): + template_name = "manifest_test/exists.html" + form_class = ManifestExistsForm + model = Integration + context_object_name = "integration" + success_message = _("Exists check has been updated") + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = self.object.manifest.get("exists", {}) + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + manifest["exists"] = form.cleaned_data + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + +class IntegrationBuilderRevokeCreateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/revoke.html" + form_class = ManifestRevokeForm + model = Integration + context_object_name = "integration" + + def form_valid(self, form): + manifest = self.object.manifest + if "revoke" not in manifest: + manifest["revoke"] = [] + manifest["revoke"].append(form.cleaned_data) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["existing_form_items"] = [ + (ManifestRevokeForm(initial=form, disabled=True), idx) + for idx, form in enumerate(self.object.manifest.get("revoke", [])) + ] + return context + + +class IntegrationBuilderRevokeUpdateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/_update_revoke.html" + form_class = ManifestRevokeForm + model = Integration + context_object_name = "integration" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = self.object.manifest["revoke"][self.kwargs["index"]] + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + manifest["revoke"][self.kwargs["index"]] = form.cleaned_data + self.object.manifest = manifest + self.object.save() + form = self.form_class( + initial=self.object.manifest["execute"][self.kwargs["index"]], disabled=True + ) + return render( + self.request, + self.template_name, + {"form": form, "index": self.kwargs["index"], "integration": self.object}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["index"] = self.kwargs["index"] + return context + + +class IntegrationBuilderRevokeDeleteView(LoginRequiredMixin, AdminPermMixin, View): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + manifest = integration.manifest + del manifest["revoke"][self.kwargs["index"]] + integration.manifest = manifest + integration.save() + return HttpResponse() + + +class IntegrationBuilderInitialDataFormCreateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/initial_data_form.html" + form_class = ManifestInitialDataForm + success_message = _("Initial data item has been added") + model = Integration + context_object_name = "integration" + + def form_valid(self, form): + manifest = self.object.manifest + if "initial_data_form" not in manifest: + manifest["initial_data_form"] = [] + manifest["initial_data_form"].append(form.cleaned_data) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["existing_form_items"] = [ + (ManifestInitialDataForm(initial=form, disabled=True), idx) + for idx, form in enumerate( + self.object.manifest.get("initial_data_form", []) + ) + ] + return context + + +class IntegrationBuilderInitialDataFormDeleteView( + LoginRequiredMixin, AdminPermMixin, View +): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + manifest = integration.manifest + # remove value from saved extra args + try: + del integration.extra_args[ + manifest["initial_data_form"][self.kwargs["index"]]["id"] + ] + integration.save() + except KeyError: + pass + del manifest["initial_data_form"][self.kwargs["index"]] + integration.manifest = manifest + integration.save() + return HttpResponse() + + +class IntegrationBuilderUserInfoFormCreateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/user_info_form.html" + form_class = ManifestUserInfoForm + success_message = _("User info item has been added") + model = Integration + context_object_name = "integration" + + def form_valid(self, form): + manifest = self.object.manifest + if "extra_user_info" not in manifest: + manifest["extra_user_info"] = [] + manifest["extra_user_info"].append(form.cleaned_data) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["existing_form_items"] = [ + (ManifestUserInfoForm(initial=form, disabled=True), idx) + for idx, form in enumerate(self.object.manifest.get("extra_user_info", [])) + ] + return context + + +class IntegrationBuilderUserInfoFormDeleteView( + LoginRequiredMixin, AdminPermMixin, View +): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + manifest = integration.manifest + del manifest["extra_user_info"][self.kwargs["index"]] + integration.manifest = manifest + integration.save() + return HttpResponse() + + +class IntegrationBuilderExecuteCreateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/execute.html" + form_class = ManifestExecuteForm + model = Integration + context_object_name = "integration" + + def form_valid(self, form): + manifest = self.object.manifest + if "execute" not in manifest: + manifest["execute"] = [] + manifest["execute"].append(form.cleaned_data) + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["existing_form_items"] = [ + (ManifestExecuteForm(initial=form, disabled=True), idx) + for idx, form in enumerate(self.object.manifest.get("execute", [])) + ] + return context + + +class IntegrationBuilderExecuteUpdateView( + LoginRequiredMixin, + AdminPermMixin, + RedirectToSelfMixin, + SingleObjectMixinWithObj, + FormView, +): + template_name = "manifest_test/_update_execute.html" + form_class = ManifestExecuteForm + model = Integration + context_object_name = "integration" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = self.object.manifest["execute"][self.kwargs["index"]] + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + manifest["execute"][self.kwargs["index"]] = form.cleaned_data + self.object.manifest = manifest + self.object.save() + form = self.form_class( + initial=self.object.manifest["execute"][self.kwargs["index"]], disabled=True + ) + return render( + self.request, + "manifest_test/execute_disabled_form.html", + {"form": form, "index": self.kwargs["index"], "integration": self.object}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["index"] = self.kwargs["index"] + return context + + +class IntegrationBuilderExecuteDeleteView(LoginRequiredMixin, AdminPermMixin, View): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + manifest = integration.manifest + del manifest["execute"][self.kwargs["index"]] + integration.manifest = manifest + integration.save() + return HttpResponse() + + +class IntegrationBuilderTestFormView(LoginRequiredMixin, AdminPermMixin, View): + def post(self, *args, **kwargs): + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + form = integration.config_form() + return render(self.request, "manifest_test/test_form.html", {"form": form}) + + +class IntegrationBuilderTestView(LoginRequiredMixin, AdminPermMixin, View): + def post(self, *args, **kwargs): + test_options = ["exists", "revoke", "execute"] + test_type = self.kwargs["what"] + if self.kwargs["what"] not in test_options: + raise Http404 + + integration = get_object_or_404(Integration, id=self.kwargs["pk"]) + extra_fields = json.loads(self.request.POST.get("extra_values", "{}")) + + try: + user = get_user_model().objects.get(id=self.request.POST.get("user", -1)) + user.extra_fields |= convert_array_to_object(extra_fields) + except (get_user_model().DoesNotExist, ValueError): + return render( + self.request, + "manifest_test/test_execute.html", + {"error": "no user selected"}, + ) + if test_type == "exists": + result = integration.user_exists(user, save_result=False) + elif test_type == "execute": + result = integration.execute(user) + elif test_type == "revoke": + result = integration.revoke_user(user) + + tracker = IntegrationTracker.objects.filter( + integration=integration, for_user=user + ).last() + + return render( + self.request, + "manifest_test/test_execute.html", + {"result": result, "integration": integration, "tracker": tracker}, + ) + + +class IntegrationBuilderOauthUpdateView( + LoginRequiredMixin, + AdminPermMixin, + SingleObjectMixinWithObj, + SuccessMessageMixin, + RedirectToSelfMixin, + FormView, +): + template_name = "manifest_test/oauth.html" + form_class = ManifestOauthForm + model = Integration + success_message = _("Oauth has been updated") + context_object_name = "integration" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["initial"] = {"oauth": self.object.manifest.get("oauth", {})} + return kwargs + + def form_valid(self, form): + manifest = self.object.manifest + manifest["oauth"] = form.cleaned_data["oauth"] + self.object.manifest = manifest + self.object.save() + return super().form_valid(form) diff --git a/back/admin/integrations/forms.py b/back/admin/integrations/forms.py index f30356c21..ababe915e 100644 --- a/back/admin/integrations/forms.py +++ b/back/admin/integrations/forms.py @@ -48,7 +48,7 @@ def _add_items(form_item): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) integration = self.instance - form = self.instance.manifest["form"] + form = self.instance.manifest.get("form", []) self.helper = FormHelper() self.helper.form_tag = False self.error = None @@ -61,7 +61,7 @@ def __init__(self, *args, **kwargs): if item["type"] in ["choice", "multiple_choice"]: # If there is a url to fetch the items from then do so - if "url" in item: + if item.get("url", "") != "": success, response = integration.run_request(item) if not success: self.error = response diff --git a/back/admin/integrations/migrations/0025_integration_is_active_and_more.py b/back/admin/integrations/migrations/0025_integration_is_active_and_more.py new file mode 100644 index 000000000..2e40144a3 --- /dev/null +++ b/back/admin/integrations/migrations/0025_integration_is_active_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-03-26 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0024_alter_integrationtracker_integration"), + ] + + operations = [ + migrations.AddField( + model_name="integration", + name="is_active", + field=models.BooleanField( + default=True, help_text="If inactive, it's a test/debug integration" + ), + ), + migrations.AddField( + model_name="integrationtrackerstep", + name="expected", + field=models.TextField(default=""), + preserve_default=False, + ), + ] diff --git a/back/admin/integrations/models.py b/back/admin/integrations/models.py index cd0f5b4e1..31097bbc3 100644 --- a/back/admin/integrations/models.py +++ b/back/admin/integrations/models.py @@ -87,12 +87,24 @@ class IntegrationTrackerStep(models.Model): method = models.TextField() post_data = models.JSONField() headers = models.JSONField() + expected = models.TextField() error = models.TextField() @property def has_succeeded(self): return self.status_code >= 200 and self.status_code < 300 + @property + def found_expected(self): + if self.expected == "": + return True + + if self.text_response != "": + return self.expected in self.text_response + if len(self.json_response): + return self.expected in json.dumps(self.json_response) + return False + @property def pretty_json_response(self): return json.dumps(self.json_response, indent=4) @@ -143,6 +155,11 @@ def import_users_options(self): ) +class IntegrationInactiveManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_active=False) + + class Integration(models.Model): class Type(models.IntegerChoices): SLACK_BOT = 0, _("Slack bot") @@ -161,6 +178,9 @@ class ManifestType(models.IntegerChoices): ) name = models.CharField(max_length=300, default="", blank=True) + is_active = models.BooleanField( + default=True, help_text="If inactive, it's a test/debug integration" + ) integration = models.IntegerField(choices=Type.choices) manifest_type = models.IntegerField( choices=ManifestType.choices, null=True, blank=True @@ -170,7 +190,7 @@ class ManifestType(models.IntegerChoices): base_url = models.CharField(max_length=22300, default="", blank=True) redirect_url = models.CharField(max_length=22300, default="", blank=True) account_id = models.CharField(max_length=22300, default="", blank=True) - active = models.BooleanField(default=True) + active = models.BooleanField(default=True) # legacy? ttl = models.IntegerField(null=True, blank=True) expiring = models.DateTimeField(auto_now_add=True, blank=True) one_time_auth_code = models.UUIDField( @@ -196,7 +216,7 @@ def skip_user_provisioning(self): @property def can_revoke_access(self): - return self.manifest.get("revoke", False) + return len(self.manifest.get("revoke", [])) @property def update_url(self): @@ -211,7 +231,6 @@ def schedule_name(self): @property def secret_values(self): - print(self.manifest["initial_data_form"]) return [ item for item in self.manifest["initial_data_form"] @@ -282,7 +301,7 @@ def save(self, *args, **kwargs): def register_manual_integration_run(self, user): from users.models import IntegrationUser - integration_user, created = IntegrationUser.objects.update_or_create( + IntegrationUser.objects.update_or_create( user=user, integration=self, defaults={"revoked": user.is_offboarding}, @@ -302,7 +321,7 @@ def run_request(self, data): post_data = self._replace_vars(json.dumps(data["data"])) else: post_data = {} - if data.get("cast_data_to_json", False): + if data.get("cast_data_to_json", True): post_data = self.cast_to_json(post_data) error = "" @@ -366,11 +385,11 @@ def run_request(self, data): except: # noqa E722 error = "There was an unexpected error with the request" - if error == "" and data.get("fail_when_4xx_response_code", True): - try: - response.raise_for_status() - except Exception: - error = response.text + if response is not None and error == "": + if len(data.get("status_code", [])) and str( + response.status_code + ) not in data.get("status_code", []): + error = f"Status code ({response.status_code}) not in allowed list ({data.get('status_code')})" try: json_response = response.json() @@ -418,6 +437,7 @@ def run_request(self, data): method=data.get("method", "POST"), post_data=json_post_payload, headers=json_headers_payload, + expected=self._replace_vars(data.get("expected", "")), error=self.clean_response(error), ) @@ -442,7 +462,7 @@ def _replace_vars(self, text): @property def has_oauth(self): - return "oauth" in self.manifest + return "oauth" in self.manifest and len(self.manifest.get("oauth", {})) def headers(self, headers=None): if headers is None: @@ -467,9 +487,12 @@ def headers(self, headers=None): new_headers[self._replace_vars(key) + ""] = self._replace_vars(value) + "" return new_headers - def user_exists(self, new_hire): + def user_exists(self, new_hire, save_result=True): from users.models import IntegrationUser + if not len(self.manifest.get("exists", [])): + return None + # check if user has been created manually if self.skip_user_provisioning: try: @@ -499,37 +522,15 @@ def user_exists(self, new_hire): if not success: return None - user_exists = ( - self._replace_vars(self.manifest["exists"]["expected"]) in response.text - ) + user_exists = self.tracker.steps.last().found_expected - IntegrationUser.objects.update_or_create( - integration=self, user=new_hire, defaults={"revoked": not user_exists} - ) + if save_result: + IntegrationUser.objects.update_or_create( + integration=self, user=new_hire, defaults={"revoked": not user_exists} + ) return user_exists - def test_user_exists(self, new_hire): - self.new_hire = new_hire - self.has_user_context = new_hire is not None - - # Renew token if necessary - if not self.renew_key(): - return _("Couldn't renew token") - - success, response = self.run_request(self.manifest["exists"]) - - if isinstance(response, str): - return _("Error when making the request: %(error)s") % {"error": response} - - user_exists = ( - self._replace_vars(self.manifest["exists"]["expected"]) in response.text - ) - - found_user = "FOUND USER" if user_exists else "COULD NOT FIND USER" - - return f"{found_user} in {response.text}" - def needs_user_info(self, user): if self.skip_user_provisioning: return False @@ -571,7 +572,7 @@ def revoke_user(self, user): for item in revoke_manifest: success, response = self.run_request(item) - if not success: + if not success or not self.tracker.steps.last().found_expected: return False, self.clean_response(response) return True, "" @@ -671,6 +672,7 @@ def execute(self, new_hire=None, params=None, retry_on_failure=False): if "name" in item and item["name"] == "generate": self.extra_args[item["id"]] = get_random_string(length=10) + response = None # Run all requests for item in self.manifest["execute"]: success, response = self.run_request(item) @@ -835,6 +837,7 @@ def clean_response(self, response) -> str: return response objects = IntegrationManager() + inactive = IntegrationInactiveManager() @receiver(post_delete, sender=Integration) diff --git a/back/admin/integrations/serializers.py b/back/admin/integrations/serializers.py index cbf7bc490..1e0329d86 100644 --- a/back/admin/integrations/serializers.py +++ b/back/admin/integrations/serializers.py @@ -29,6 +29,13 @@ class ManifestFormSerializer(ValidateMixin, serializers.Serializer): data_from = serializers.CharField(required=False) choice_value = serializers.CharField(required=False) choice_name = serializers.CharField(required=False) + options_source = serializers.ChoiceField( + [ + ("fixed list", "fixed list"), + ("fetch url", "fetch url"), + ], + required=False, + ) class ManifestConditionSerializer(ValidateMixin, serializers.Serializer): @@ -44,7 +51,9 @@ class ManifestPollingSerializer(ValidateMixin, serializers.Serializer): class ManifestExistSerializer(ValidateMixin, serializers.Serializer): url = serializers.CharField() expected = serializers.CharField() - fail_when_4xx_response_code = serializers.BooleanField(required=False) + status_code = serializers.ListField( + child=serializers.IntegerField(), required=False + ) method = serializers.ChoiceField( [ ("GET", "GET"), @@ -67,6 +76,9 @@ class ManifestExecuteSerializer(ValidateMixin, serializers.Serializer): ("PUT", "PUT"), ] ) + status_code = serializers.ListField( + child=serializers.IntegerField(), required=False + ) files = serializers.DictField(child=serializers.CharField(), default=dict) save_as_file = serializers.CharField(required=False) polling = ManifestPollingSerializer(required=False) @@ -86,6 +98,9 @@ def validate(self, data): class ManifestRevokeSerializer(ValidateMixin, serializers.Serializer): url = serializers.CharField() data = serializers.JSONField(required=False, default=dict) + status_code = serializers.ListField( + child=serializers.IntegerField(), required=False + ) headers = serializers.DictField(child=serializers.CharField(), default=dict) method = serializers.ChoiceField( [ diff --git a/back/admin/integrations/templates/manifest_test.html b/back/admin/integrations/templates/manifest_test.html index 997712561..3973e1f06 100644 --- a/back/admin/integrations/templates/manifest_test.html +++ b/back/admin/integrations/templates/manifest_test.html @@ -1,401 +1,251 @@ {% extends 'admin_base.html' %} {% load i18n static %} {% load crispy_forms_tags %} + {% block extra_css %} {% endblock %} {% block actions %} - - - - - {% endblock %} {% block content %} -{% if manifest %} -{{ manifest|json_script:"manifest" }} -{% endif %} -
{% trans "The form option is used when this integration gets added to a user or in a sequence to add specific details. For example: you want to create a new user in a third party application, but you want to be able to select to which group they will be added. You can do that with this option." %}
-{% trans "Check if a user already exists in the third party service. Leave empty to ignore." %}
-{% trans "This will run when the integration gets triggered." %}
-{% trans "This will revoke a user when the integration is triggered in the offboarding sequence or by revoking the user by clicking on the button" %}
-{% trans "The default headers to send with each request. Can be overwritten through the other parts." %}
-{% trans "Mostly very company specific or sensitive details that will be used to process requests." %}
-{% trans "When an integration needs extra user info (a notification will be added to add this info)" %}
-{% trans "Nothing has ran yet" %}
-{% trans "Please select a user to test this integration on and fill in extra fields if they need them" %}
-{% trans "Extra info necessary to do the request (api keys etc)" %}
-{% trans "This will run when the integration gets triggered." %}
+{% trans "Check if a user already exists in the third party service. Leave empty to ignore." %}
+ +{% trans "The form option is used when this integration gets added to a user or in a sequence to add specific details. For example: you want to create a new user in a third party application, but you want to be able to select to which group they will be added. You can do that with this option." %}
+{% trans "The default headers to send with each request. Can be overwritten through the other parts." %}
+ +{% trans "Additional items that will be needed to run this integration. Think of api keys or a domain name for example." %}
+ + {% translate "Update credentials" %} + ++ {{ field.help_text }} +
+{% trans "If you need to use OAuth2 to get a token, then you will need to use this. See all settings here: Oauth documentation" %}
+ +{% trans "This will revoke a user when the integration is triggered in the offboarding sequence or by revoking the user by clicking on the button" %}
+{% trans "When an integration needs extra user info (a notification will be added to add this info). Make sure to 'mock' these values in the right column, for testing purposes." %}
+ {% for item, id in existing_form_items %} +{{ step.method }}: {{ step.url }}
--{{ step.pretty_json_response }} --
-{{ step.pretty_post_data }} --
-{{ step.pretty_headers }} -- {% if step.error != "" %} -
{{ step.method }}: {{ step.url }}
++{{ step.pretty_json_response }} ++
+{{ step.pretty_post_data }} ++
+{{ step.pretty_headers }} ++{% if step.error != "" %} +
{% translate "Expected value: " %}
{{step.expected}}(Found: {{step.found_expected}}) +{% endif %} diff --git a/back/admin/integrations/templates/value_key_array_field.html b/back/admin/integrations/templates/value_key_array_field.html new file mode 100644 index 000000000..896654017 --- /dev/null +++ b/back/admin/integrations/templates/value_key_array_field.html @@ -0,0 +1,44 @@ +{% load crispy_forms_field %} +{% load i18n %} + + +
+ {{ field.help_text }} +
+" + json.dumps(manifest, indent=4) + "") diff --git a/back/admin/sequences/templates/_item_form.html b/back/admin/sequences/templates/_item_form.html index 95ccc1c57..91846439d 100644 --- a/back/admin/sequences/templates/_item_form.html +++ b/back/admin/sequences/templates/_item_form.html @@ -11,7 +11,10 @@ {% crispy form %} {% else %} - {{ form.error }} +
+ {{ form.error }} +{% endif %} diff --git a/back/admin/settings/templates/settings_integrations.html b/back/admin/settings/templates/settings_integrations.html index 1b94dc7e5..5e70129c7 100644 --- a/back/admin/settings/templates/settings_integrations.html +++ b/back/admin/settings/templates/settings_integrations.html @@ -48,7 +48,7 @@
{% translate "Inactive/Test integrations" %} | ++ |
---|---|
{{ integration.name }} | ++ {% if integration.has_oauth and not integration.enabled_oauth %} + + {% translate "OAuth" %} + + {% endif %} + {% if "initial_data_form" in integration.manifest %} + + {% translate "Update credentials" %} + + {% endif %} + + {% translate "Update manifest" %} + + + {% translate "Live edit and test integration" %} + + + {% translate "Remove" %} + + | +
{% trans "No test/debug integrations yet" %} | +