Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create user defined forms app #882

Merged
merged 39 commits into from Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
19a093d
Preliminary work on replacing prefilter logic with filtersets
rabstejnek Aug 10, 2023
98f69fc
Data pivots largely done
rabstejnek Aug 21, 2023
cdede6f
Visuals largely done
rabstejnek Aug 21, 2023
7cee82b
Cleanup, linting
rabstejnek Aug 22, 2023
89d2f58
Improvements
rabstejnek Aug 23, 2023
228d876
Fix tests
rabstejnek Aug 23, 2023
ca90cf1
create initial form library classes
munnsmunns Aug 24, 2023
c2175de
migrate needed files and add new app
munnsmunns Aug 28, 2023
14a4d64
add to apps
munnsmunns Aug 28, 2023
ab0e7eb
add validation to form
munnsmunns Aug 28, 2023
66e0031
add migration and admin site
munnsmunns Aug 29, 2023
b4e1e37
add url and get form view running
munnsmunns Aug 29, 2023
249b751
improve form layout
munnsmunns Aug 29, 2023
7d74a7d
migrate tests from hero
munnsmunns Aug 29, 2023
aaf6159
ruff
munnsmunns Aug 29, 2023
0c8a9bc
enable feature flag for tests
munnsmunns Aug 29, 2023
7cb09df
fix settings file
munnsmunns Aug 29, 2023
3d1ae6d
skip failing test for now
munnsmunns Aug 29, 2023
34aab55
add more fields from review
munnsmunns Aug 31, 2023
b13a478
add fields to form
munnsmunns Aug 31, 2023
cc8829c
lint
munnsmunns Aug 31, 2023
b64f959
fix test
shapiromatron Sep 7, 2023
441f6b5
set the creator outside with data from request, not form fields
shapiromatron Sep 7, 2023
b3ee0a8
updates from review
munnsmunns Sep 12, 2023
9c8dfac
remove comment
munnsmunns Sep 12, 2023
300736c
fix urls
munnsmunns Sep 12, 2023
3729afa
vendor dynamic forms
shapiromatron Sep 16, 2023
848f016
Merge branch 'dynamic-forms' into form-library
shapiromatron Sep 16, 2023
aa4caba
fix merge conflicts
shapiromatron Sep 17, 2023
9500b80
revert db fixture
shapiromatron Sep 17, 2023
99660b4
use regex
shapiromatron Sep 17, 2023
3c3e384
remove ID
shapiromatron Sep 17, 2023
59ae0d4
rename to udf
shapiromatron Sep 17, 2023
2978b32
lint; add test UDF to db schema
shapiromatron Sep 17, 2023
f853b49
dont hard-code model related models
shapiromatron Sep 17, 2023
f7ea9c4
validate field name via regex
shapiromatron Sep 17, 2023
deb5d97
Merge branch 'dynamic-forms' into form-library
shapiromatron Sep 17, 2023
efd1e4f
Merge branch 'main' into form-library
shapiromatron Sep 18, 2023
ffcda36
Merge branch 'main' into form-library
shapiromatron Sep 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions hawc/apps/common/dynamic_forms/__init__.py
@@ -0,0 +1,5 @@
from ..forms import DynamicFormField
from .forms import DynamicForm
from .schemas import Schema

__all__ = ["DynamicForm", "DynamicFormField", "Schema"]
71 changes: 71 additions & 0 deletions hawc/apps/common/dynamic_forms/constants.py
@@ -0,0 +1,71 @@
"""Django form enums."""
from enum import Enum

from django import forms

from ..forms import InlineRadioChoiceField


class FormField(Enum):
"""Django form field enumeration.

When removing access to a member, please
comment out instead of omitting.
"""

BOOLEAN = forms.BooleanField
CHAR = forms.CharField
CHOICE = forms.ChoiceField
DATE = forms.DateField
DATE_TIME = forms.DateTimeField
DECIMAL = forms.DecimalField
DURATION = forms.DurationField
EMAIL = forms.EmailField
FILE = forms.FileField
FILEPATH = forms.FilePathField
FLOAT = forms.FloatField
GENERIC_IP_ADDRESS = forms.GenericIPAddressField
IMAGE = forms.ImageField
INTEGER = forms.IntegerField
JSON = forms.JSONField
MULTIPLE_CHOICE = forms.MultipleChoiceField
NULL_BOOLEAN = forms.NullBooleanField
REGEX = forms.RegexField
SLUG = forms.SlugField
TIME = forms.TimeField
TYPED_CHOICE = forms.TypedChoiceField
TYPED_MULTIPLE_CHOICE = forms.TypedMultipleChoiceField
URL = forms.URLField
UUID = forms.UUIDField
YES_NO = InlineRadioChoiceField


class Widget(Enum):
"""Django widget enumeration.

When removing access to a member, please
comment out instead of omitting.
"""

TEXT_INPUT = forms.TextInput
NUMBER_INPUT = forms.NumberInput
EMAIL_INPUT = forms.EmailInput
URL_INPUT = forms.URLInput
PASSWORD_INPUT = forms.PasswordInput
HIDDEN_INPUT = forms.HiddenInput
DATE_INPUT = forms.DateInput
DATE_TIME_INPUT = forms.DateTimeInput
TIME_INPUT = forms.TimeInput
TEXTAREA = forms.Textarea
CHECKBOX_INPUT = forms.CheckboxInput
SELECT = forms.Select
NULL_BOOLEAN_SELECT = forms.NullBooleanSelect
SELECT_MULTIPLE = forms.SelectMultiple
RADIO_SELECT = forms.RadioSelect
CHECKBOX_SELECT_MULTIPLE = forms.CheckboxSelectMultiple
FILE_INPUT = forms.FileInput
CLEARABLE_FILE_INPUT = forms.ClearableFileInput
MULTIPLE_HIDDEN_INPUT = forms.MultipleHiddenInput
SPLIT_DATE_TIME = forms.SplitDateTimeWidget
SPLIT_HIDDEN_DATE_TIME = forms.SplitHiddenDateTimeWidget
SELECT_DATE = forms.SelectDateWidget
139 changes: 139 additions & 0 deletions hawc/apps/common/dynamic_forms/fields.py
@@ -0,0 +1,139 @@
"""Dynamic Django form fields."""
from typing import Annotated, Any, Literal

from django import forms
from django.utils.html import conditional_escape
from pydantic import BaseModel, validator
from pydantic import Field as PydanticField

from . import constants


class _Field(BaseModel):
"""Base class for Django form field schemas.

This class should only act as a superclass for each specific Django form field.

Each subclass should add a 'type' property that corresponds with a key
in constants.FormField, and a 'widget' property that corresponds with keys
in constants.Widget.
"""

name: str # the variable name in the form; extra validation for no whitespace etc?
shapiromatron marked this conversation as resolved.
Show resolved Hide resolved
required: bool | None
label: str | None
label_suffix: str | None
initial: Any = None
help_text: str | None
css_class: str | None
# error_messages
# validators
# localize
# disabled

class Config:
"""Schema config."""

underscore_attrs_are_private = True
use_enum_values = True

@validator("help_text")
def ensure_safe_string(cls, v):
"""Sanitize help_text values."""
if v is None:
return v
return conditional_escape(v)

def get_form_field_kwargs(self) -> dict:
"""Get keyword arguments for Django form field."""
kwargs = self.dict(exclude={"type", "widget", "name", "css_class"}, exclude_none=True)
kwargs["widget"] = constants.Widget[self.widget.upper()].value
return kwargs

def get_form_field(self) -> forms.Field:
"""Get Django form field."""
form_field_cls = constants.FormField[self.type.upper()].value
form_field_kwargs = self.get_form_field_kwargs()
return form_field_cls(**form_field_kwargs)

def get_verbose_name(self) -> str:
"""Get reader friendly name for schema."""
return self.label or self.name.replace("_", " ").title()


class BooleanField(_Field):
"""Boolean field."""

type: Literal["boolean"] = "boolean"
widget: Literal["checkbox_input"] = "checkbox_input"


class CharField(_Field):
"""Character field."""

type: Literal["char"] = "char"
widget: Literal["text_input", "textarea"] = "text_input"

max_length: int | None
min_length: int | None
strip: bool | None
empty_value: str | None


class IntegerField(_Field):
"""Integer field."""

type: Literal["integer"] = "integer"
widget: Literal["number_input"] = "number_input"

min_value: int | None
max_value: int | None


class FloatField(_Field):
"""Float field."""

type: Literal["float"] = "float"
widget: Literal["number_input"] = "number_input"

min_value: int | None
max_value: int | None


class ChoiceField(_Field):
"""Choice field."""

type: Literal["choice"] = "choice"
widget: Literal["select", "radio_select"] = "select"

choices: list[tuple[str, str]]


class YesNoChoiceField(_Field):
"""Yes No field."""

type: Literal["yes_no"] = "yes_no"
widget: Literal["radio_select"] = PydanticField("radio_select", const=True)

choices: list[tuple[str, str]] = PydanticField([("yes", "Yes"), ("no", "No")], const=True)


class MultipleChoiceField(_Field):
"""Multiple choice field."""

type: Literal["multiple_choice"] = "multiple_choice"
widget: Literal["select_multiple", "checkbox_select_multiple"] = "select_multiple"

choices: list[tuple[str, str]]


Field = Annotated[
BooleanField
| CharField
| ChoiceField
| YesNoChoiceField
| FloatField
| IntegerField
| MultipleChoiceField,
PydanticField(discriminator="type"),
]
124 changes: 124 additions & 0 deletions hawc/apps/common/dynamic_forms/forms.py
@@ -0,0 +1,124 @@
"""Dynamic Django forms."""
import json

from crispy_forms import layout as cfl
from django import forms

from ..forms import BaseFormHelper
from .schemas import Behavior, Schema


class DynamicForm(forms.Form):
"""Dynamic Django form.

This is built from a custom schema that defines fields and conditional logic.
"""

def __init__(self, schema: Schema, *args, **kwargs):
"""Create dynamic form."""
super().__init__(*args, **kwargs)
self.schema = schema
fields = {f.name: f.get_form_field() for f in self.schema.fields}
self.fields.update(fields)

@property
def helper(self):
"""Django crispy form helper."""
helper = DynamicFormHelper(self)

# wrap up field inputs with bootstrap grid
helper.auto_wrap_fields()

# expose serialized conditions w/ unique id to template
helper.conditions = [
{
"subject_id": self[condition.subject].auto_id,
"observer_ids": [self[observer].auto_id for observer in condition.observers],
"comparison": condition.comparison,
"comparison_value": condition.comparison_value,
"behavior": condition.behavior,
}
for condition in self.schema.conditions
]
helper.conditions_id = f"conditions-{hash(json.dumps(helper.conditions))}"
helper.layout.append(cfl.HTML("{{ conditions|json_script:conditions_id }}"))

return helper

def full_clean(self):
"""Overridden full_clean that handles conditional logic from schema."""
# handle conditions
if self.is_bound:
fields = self.fields.copy() # copy to restore after clean
data = self.data.copy() # copy in case it's immutable
for condition in self.schema.conditions:
bf = self[condition.subject]
value = bf.field.to_python(bf.value())
check = condition.comparison.compare(value, condition.comparison_value)
show = check if condition.behavior == Behavior.SHOW else not check
if not show:
for observer in condition.observers:
# remove data that should be hidden
data.pop(observer, None)
# remove fields that should be hidden;
# this is restored after clean
self.fields.pop(observer)
self.data = data

# run default full_clean
super().full_clean()

# restore fields
if self.is_bound:
self.fields = fields


class DynamicFormHelper(BaseFormHelper):
"""Django crispy form helper."""

form_tag = False

def auto_wrap_fields(self):
"""Wrap fields in bootstrap classes."""
if len(self.form.schema.fields) == 0:
return

for field in self.form.schema.fields:
index = self.layout.index(field.name)
css_class = field.css_class or "col-12"
self[index].wrap(cfl.Column, css_class=css_class)

self[:].wrap_together(cfl.Row, id="row_id_dynamic_form")
shapiromatron marked this conversation as resolved.
Show resolved Hide resolved
self.add_field_wraps()


class DynamicFormWidget(forms.Widget):
"""Widget to display dynamic form inline."""

template_name = "common/widgets/dynamic_form.html"

def __init__(self, prefix, form_class, form_kwargs=None, *args, **kwargs):
"""Create dynamic form widget."""
super().__init__(*args, **kwargs)
self.prefix = prefix
self.form_class = form_class
if form_kwargs is None:
form_kwargs = {}
self.form_kwargs = {"prefix": prefix, **form_kwargs}

def add_prefix(self, field_name):
"""Add prefix in the same way Django forms add prefixes."""
return f"{self.prefix}-{field_name}"

def format_value(self, value):
"""Value used in rendering."""
value = json.loads(value)
if value:
value = {self.add_prefix(k): v for k, v in value.items()}
return self.form_class(data=value, **self.form_kwargs)

def value_from_datadict(self, data, files, name):
"""Parse value from POST request."""
form = self.form_class(data=data, **self.form_kwargs)
form.full_clean()
return form.cleaned_data