Merge pull request 'Added basic dynamic form config' (#18) from feature/dynamic_forms into main
ci/woodpecker/push/build Pipeline was successful Szczegóły
ci/woodpecker/tag/build Pipeline was successful Szczegóły

Reviewed-on: #18
fix/menu_translation 0.4.0
mtyton 2023-10-22 08:23:43 +00:00
commit d98f1a42d2
14 zmienionych plików z 706 dodań i 3 usunięć

Wyświetl plik

@ -42,6 +42,7 @@ INSTALLED_APPS = [
"mailings",
"blog",
"search",
"dynamic_forms",
"setup",
"wagtail_localize",
"wagtail_localize.locales",
@ -57,6 +58,7 @@ INSTALLED_APPS = [
"wagtail.search",
"wagtail.admin",
'wagtail.contrib.modeladmin',
"wagtail.contrib.settings",
"wagtail",
"wagtailmenus",
"modelcluster",
@ -71,7 +73,7 @@ INSTALLED_APPS = [
"phonenumber_field",
"django_celery_results",
"django_celery_beat",
"easy_thumbnails",
"easy_thumbnails"
]

Wyświetl plik

@ -67,7 +67,7 @@
<div class="col-md-11 mt-5 ml-2"></div>
<div class="col-md-1 mt-5 ml-2">
{% if page %}
{% if page and page.get_translations.live %}
<div class="dropdown">
<button class="btn btn-outline-secondary btn-lg" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<img src="{% static 'images/icons/globe.svg' %}">

Wyświetl plik

@ -0,0 +1,45 @@
from wagtail.contrib.modeladmin.options import (
ModelAdmin,
ModelAdminGroup,
modeladmin_register
)
from dynamic_forms import models
class CustomEmailFormAdmin(ModelAdmin):
model = models.CustomEmailForm
menu_label = "Email Forms"
menu_icon = 'mail'
menu_order = 100
add_to_settings_menu = False
exclude_from_explorer = False
list_display = (
"title",
"slug",
)
search_fields = (
"title",
"slug",
)
list_filter = (
"title",
"slug",
)
form_fields = (
"slug",
"intro",
"thank_you_text",
"from_address",
"to_address",
"subject",
)
class CustomFormGroup(ModelAdminGroup):
menu_label = "Custom Forms"
menu_icon = 'tasks'
menu_order = 100
items = (
CustomEmailFormAdmin,
)
modeladmin_register(CustomFormGroup)

Wyświetl plik

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DynamicFormsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dynamic_forms"

Wyświetl plik

@ -0,0 +1,58 @@
from django import forms
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = single_file_clean(data, initial)
return result
class DynamicForm(forms.Form):
FIELD_TYPE_MAPPING = {
"singleline": forms.CharField(max_length=50, widget=forms.TextInput(attrs={"class": "form-control"})),
"multiline": forms.CharField(max_length=255, widget=forms.Textarea(attrs={"class": "form-control"})),
"email": forms.EmailField(max_length=255, widget=forms.EmailInput(attrs={"class": "form-control"})),
"number": forms.IntegerField(widget=forms.NumberInput(attrs={"class": "form-control"})),
"url": forms.URLField(max_length=255, widget=forms.URLInput(attrs={"class": "form-control"})),
"checkbox": forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={"class": "form-control"})),
"checkboxes": forms.MultipleChoiceField(required=False, widget=forms.CheckboxSelectMultiple(attrs={"class": "form-control"})),
"dropdown": forms.ChoiceField(widget=forms.Select(attrs={"class": "form-control"})),
"multiselect": forms.MultipleChoiceField(widget=forms.SelectMultiple(attrs={"class": "form-control"})),
"radio": forms.ChoiceField(widget=forms.RadioSelect(attrs={"class": "form-control"})),
"date": forms.DateField(widget=forms.DateInput(attrs={"class": "form-control"})),
"datetime": forms.DateTimeField(widget=forms.DateTimeInput(attrs={"class": "form-control"})),
"hidden": forms.CharField(widget=forms.HiddenInput()),
}
def __init__(self, *args, **kwargs) -> None:
kwargs.pop("page", "")
kwargs.pop("user", "")
field_list = kwargs.pop("field_list")
file_uploads = kwargs.pop("file_uploads", False)
super().__init__(*args, **kwargs)
for field in field_list:
f = self.FIELD_TYPE_MAPPING[field.field_type]
f.label = field.label
if hasattr(f, "choices"):
f.choices = [(v, v) for v in field.choices.split(",")]
f.required = field.required
self.fields[field.clean_name] = f
if file_uploads:
self.fields["attachments"] = MultipleFileField(
required=True, widget=MultipleFileInput(
attrs={"class": "form-control"}
)
)

Wyświetl plik

@ -0,0 +1,132 @@
# Generated by Django 4.1.11 on 2023-10-12 17:23
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import modelcluster.fields
import wagtail.contrib.forms.models
import wagtail.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
("wagtailcore", "0083_workflowcontenttype"),
]
operations = [
migrations.CreateModel(
name="CustomEmailForm",
fields=[
(
"page_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="wagtailcore.page",
),
),
("intro", wagtail.fields.RichTextField(blank=True)),
("thank_you_text", wagtail.fields.RichTextField(blank=True)),
("allow_attachments", models.BooleanField(default=False)),
("from_address", models.EmailField(blank=True, help_text="Sender email address", max_length=254)),
("to_address", models.CharField(help_text="Comma separated list of recipients", max_length=255)),
("subject", models.CharField(help_text="Subject of the email with data", max_length=255)),
],
options={
"abstract": False,
},
bases=(wagtail.contrib.forms.models.FormMixin, "wagtailcore.page"),
),
migrations.CreateModel(
name="EmailFormSubmission",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("form_data", models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder)),
("submit_time", models.DateTimeField(auto_now_add=True, verbose_name="submit time")),
("page", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="wagtailcore.page")),
],
options={
"verbose_name": "form submission",
"verbose_name_plural": "form submissions",
"abstract": False,
},
),
migrations.CreateModel(
name="EmailFormField",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sort_order", models.IntegerField(blank=True, editable=False, null=True)),
(
"clean_name",
models.CharField(
blank=True,
default="",
help_text="Safe name of the form field, the label converted to ascii_snake_case",
max_length=255,
verbose_name="name",
),
),
(
"label",
models.CharField(help_text="The label of the form field", max_length=255, verbose_name="label"),
),
(
"field_type",
models.CharField(
choices=[
("singleline", "Single line text"),
("multiline", "Multi-line text"),
("email", "Email"),
("number", "Number"),
("url", "URL"),
("checkbox", "Checkbox"),
("checkboxes", "Checkboxes"),
("dropdown", "Drop down"),
("multiselect", "Multiple select"),
("radio", "Radio buttons"),
("date", "Date"),
("datetime", "Date/time"),
("hidden", "Hidden field"),
],
max_length=16,
verbose_name="field type",
),
),
("required", models.BooleanField(default=True, verbose_name="required")),
(
"choices",
models.TextField(
blank=True,
help_text="Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown.",
verbose_name="choices",
),
),
(
"default_value",
models.TextField(
blank=True,
help_text="Default value. Comma or new line separated values supported for checkboxes.",
verbose_name="default value",
),
),
("help_text", models.CharField(blank=True, max_length=255, verbose_name="help text")),
(
"form",
modelcluster.fields.ParentalKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="form_fields",
to="dynamic_forms.customemailform",
),
),
],
options={
"ordering": ["sort_order"],
"abstract": False,
},
),
]

Wyświetl plik

@ -0,0 +1,123 @@
import datetime
from django.db import models
from django.conf import settings
from django.utils.formats import date_format
from modelcluster.fields import ParentalKey
from wagtail.admin.panels import (
FieldPanel, FieldRowPanel,
InlinePanel, MultiFieldPanel
)
from wagtail.fields import RichTextField
from wagtail.contrib.forms.models import (
AbstractFormField,
FormMixin,
Page,
AbstractFormSubmission
)
from mailings.models import (
OutgoingEmail,
Attachment
)
from dynamic_forms.forms import DynamicForm
class Form(FormMixin, Page):
intro = RichTextField(blank=True)
thank_you_text = RichTextField(blank=True)
allow_attachments = models.BooleanField(default=False)
content_panels = Page.content_panels + [
FieldPanel('intro'),
InlinePanel('form_fields', label="Form fields"),
FieldPanel('thank_you_text'),
MultiFieldPanel([
FieldRowPanel([
FieldPanel('from_address', classname="col6"),
FieldPanel('to_address', classname="col6"),
]),
FieldPanel('subject'),
], "Email"),
FieldPanel("allow_attachments")
]
def get_form_class(self):
return DynamicForm
def get_form(self, *args, **kwargs):
form_class = self.get_form_class()
form_params = self.get_form_parameters()
form_params.update(kwargs)
form_params["field_list"] = self.get_form_fields()
form_params["file_uploads"] = self.allow_attachments
return form_class(*args, **form_params)
class Meta:
abstract = True
class EmailFormSubmission(AbstractFormSubmission):
def send_mail(self, data):
# modify this, get proper template
to_addresses = data.pop("to_address").split(",")
attachments = [
Attachment(
file.name, file.file.read(), file.content_type
)
for file in data.pop("attachments", [])
]
subject = data.get("subject")
from_address = data.pop("from_address", settings.DEFAULT_FROM_EMAIL)
for address in to_addresses:
OutgoingEmail.objects.send(
subject=subject,
template_name="form_mail",
recipient=address,
sender=from_address,
context=data,
attachments=attachments
)
class CustomEmailForm(Form):
from_address = models.EmailField(
blank=True,
help_text="Sender email address"
)
to_address = models.CharField(
max_length=255,
help_text="Comma separated list of recipients"
)
subject = models.CharField(
max_length=255,
help_text="Subject of the email with data"
)
template = "forms/email_form_page.html"
def get_submission_class(self):
return EmailFormSubmission
def process_form_submission(self, form):
attachments = form.cleaned_data.pop("attachments", [])
submission = self.get_submission_class().objects.create(
form_data=form.cleaned_data,
page=self,
)
mail_data = form.cleaned_data.copy()
mail_data.update({
"from_address": self.from_address,
"to_address": self.to_address,
"subject": self.subject,
"attachments": attachments
})
submission.send_mail(data=mail_data)
return submission
class EmailFormField(AbstractFormField):
form = ParentalKey(
"CustomEmailForm", related_name="form_fields", on_delete=models.CASCADE
)

Wyświetl plik

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% load i18n %}
{% block content %}
<h1>{{ page.title }}</h1>
<p class="meta">{{ page.date }}</p>
{% if form %}
<div>{{ page.intro|richtext }}</div>
<form enctype="multipart/form-data" action="{% pageurl page %}" method="POST">
{% csrf_token %}
{% for field in form %}
<div class="form-group mt-3">
<label for="{{field.id}}" class="form-label">
{% trans field.label %}
</label>
{{field}}
<small id="emailHelp" class="form-text text-muted">
{% trans field.help_text %}
</small>
</div>
{% endfor %}
<div class="text-end mt-3">
<input class="btn btn-lg btn-success" type="submit" value='{% trans "Submit" %}'>
</div>
</form>
{% else %}
<div>You can fill in the from only one time.</div>
{% endif %}
{% endblock %}

Wyświetl plik

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load i18n %}
{% load wagtailcore_tags wagtailimages_tags %}
{% block content %}
<div class="d-flex justify-content-center align-items-center">
<div class="card col-9 bg-white shadow-md p-5">
<div class="mb-4 text-center">
<svg class="text-success" width="75" height="75"
fill="currentColor" class="bi bi-check-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path
d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z" />
</svg>
</div>
<div class="text-center">
<div>
{{page.thank_you_text|richtext}}
</div>
<a class="btn btn-outline-primary mt-3" href="/">
{% trans "Homepage" %}
</a>
</div>
</div>
{% endblock %}

Wyświetl plik

@ -0,0 +1,277 @@
from wagtail.tests.utils import WagtailPageTests
from django import forms
from django.core.files.uploadedfile import SimpleUploadedFile
from dynamic_forms.models import (
CustomEmailForm,
EmailFormField
)
from dynamic_forms.forms import DynamicForm
class CustomEmailFormTestCase(WagtailPageTests):
def setUp(self):
self.form = CustomEmailForm.objects.create(
slug="test", title="Test Form", path="test",
depth=0, numchild=0, live=True, has_unpublished_changes=False,
from_address="comfy-test@egalitare.pl",
to_address="comfy-dest@egalitare.pl",
subject="Test Form", allow_attachments=False,
)
EmailFormField.objects.create(
label="Name",
field_type="singleline",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Message",
field_type="multiline",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Email",
field_type="email",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Number",
field_type="number",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="URL",
field_type="url",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Checkbox",
field_type="checkbox",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Checkboxes",
field_type="checkboxes",
required=True,
choices="a,b,c",
form=self.form
)
EmailFormField.objects.create(
label="Dropdown",
field_type="dropdown",
required=True,
choices="a,b,c",
form=self.form
)
EmailFormField.objects.create(
label="MultiSelect",
field_type="multiselect",
required=True,
choices="a,b,c",
form=self.form
)
EmailFormField.objects.create(
label="Radio",
field_type="radio",
required=True,
choices="a,b,c",
form=self.form
)
EmailFormField.objects.create(
label="Date",
field_type="date",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="DateTime",
field_type="datetime",
required=True,
form=self.form
)
EmailFormField.objects.create(
label="Hidden",
field_type="hidden",
required=True,
form=self.form
)
def test_generate_html_form_from_model(self):
html_form = self.form.get_form()
self.assertIsInstance(html_form, DynamicForm)
self.assertEqual(len(html_form.fields), 13)
self.assertEqual(html_form.fields["name"].label, "Name")
self.assertEqual(html_form.fields["name"].required, True)
self.assertEqual(html_form.fields["name"].widget.attrs["class"], "form-control")
self.assertIsInstance(
html_form.fields["name"],
forms.CharField
)
self.assertIsInstance(
html_form.fields["message"],
forms.CharField
)
self.assertIsInstance(
html_form.fields["email"].widget,
forms.EmailInput
)
self.assertIsInstance(
html_form.fields["number"].widget,
forms.NumberInput
)
def test_create_form_submission_success_without_files(self):
form_data = {
# generate data for this class self.form.get_form()
"name": "Test",
"message": "Test message",
"email": "test@test.com",
"number": 1,
"url": "http://example.com",
"checkbox": True,
"checkboxes": ["a", "b"],
"dropdown": "a",
"multiselect": ["a", "b"],
"radio": "a",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"hidden": "hidden",
}
form = self.form.get_form(form_data)
self.assertTrue(form.is_valid())
# change field not to be required
field = EmailFormField.objects.get(
label="Name", form=self.form
)
field.required = False
field.save()
form = self.form.get_form(form_data)
self.assertTrue(form.is_valid())
# it should also work without this field
form_data.pop("name")
form = self.form.get_form(form_data)
self.assertTrue(form.is_valid())
def test_create_form_submission_success_with_files(self):
self.form.allow_attachments = True
self.form.save()
form_data = {
# generate data for this class self.form.get_form()
"name": "Test",
"message": "Test message",
"email": "test@test.com",
"number": 1,
"url": "http://example.com",
"checkbox": True,
"checkboxes": ["a", "b"],
"dropdown": "a",
"multiselect": ["a", "b"],
"radio": "a",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"hidden": "hidden"
}
files = {
"attachments": [SimpleUploadedFile("test.txt", b"test")],
}
form = self.form.get_form(form_data, files=files)
self.assertTrue(form.is_valid())
def test_create_form_submission_failure_without_files_missing_data(self):
form_data = {
# generate data for this class self.form.get_form()
"message": "Test message",
"email": "test@test.com",
"number": 1,
"url": "http://example.com",
"checkbox": True,
"checkboxes": ["a", "b"],
"dropdown": "a",
"multiselect": ["a", "b"],
"radio": "a",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"hidden": "hidden",
}
form = self.form.get_form(form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors["name"], ['This field is required.'])
form_data.pop("url")
form = self.form.get_form(form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 2)
self.assertEqual(form.errors["name"], ['This field is required.'])
self.assertEqual(form.errors["url"], ['This field is required.'])
# make Field not required
field = EmailFormField.objects.get(
label="Hidden", form=self.form
)
field.required = False
field.save()
# it should also work without this field
form_data.pop("hidden")
form = self.form.get_form(form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 2)
self.assertEqual(form.errors["name"], ['This field is required.'])
self.assertEqual(form.errors["url"], ['This field is required.'])
def test_create_form_submission_failure_with_files_missing_data(self):
self.form.allow_attachments = True
self.form.save()
form_data = {
# generate data for this class self.form.get_form()
"message": "Test message",
"email": "test@test.com",
"number": 1,
"url": "http://example.com",
"checkbox": True,
"checkboxes": ["a", "b"],
"dropdown": "a",
"multiselect": ["a", "b"],
"radio": "a",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"hidden": "hidden",
}
files = {
"attachments": [SimpleUploadedFile("test.txt", b"test")],
}
form = self.form.get_form(form_data, files=files)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors["name"], ['This field is required.'])
form_data.pop("url")
form = self.form.get_form(form_data, files=files)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 2)
self.assertEqual(form.errors["name"], ['This field is required.'])
self.assertEqual(form.errors["url"], ['This field is required.'])
# make Field not required
field = EmailFormField.objects.get(
label="Hidden", form=self.form
)
field.required = False
field.save()
# it should also work without this field
form_data.pop("hidden")
form = self.form.get_form(form_data, files=files)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 2)
self.assertEqual(form.errors["name"], ['This field is required.'])
self.assertEqual(form.errors["url"], ['This field is required.'])
# Now try without files
form = self.form.get_form(form_data, files={})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 3)
self.assertEqual(form.errors["name"], ['This field is required.'])
self.assertEqual(form.errors["url"], ['This field is required.'])
self.assertEqual(form.errors["attachments"], ['This field is required.'])

Wyświetl plik

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

Wyświetl plik

@ -4,7 +4,7 @@
<div class="d-flex justify-content-center align-items-center">
<div class="card col-9 bg-white shadow-md p-5">
<div class="mb-4 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-success" width="75" height="75"
<svg class="text-success" width="75" height="75"
fill="currentColor" class="bi bi-check-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path