- Refining & Enhancing dynamic fields

- Creating a specialized Form class for handling dynamic fields
- Refinement of HTML-macros to handle dynamic fields
- Introduction of dynamic fields for Catalogs
This commit is contained in:
Josako
2024-10-29 09:17:44 +01:00
parent aa358df28e
commit 43547287b1
13 changed files with 605 additions and 129 deletions

View File

@@ -1,6 +1,11 @@
from flask_wtf import FlaskForm
from wtforms import IntegerField, FloatField, BooleanField, StringField, validators
from wtforms import IntegerField, FloatField, BooleanField, StringField, TextAreaField, validators, ValidationError
from flask import current_app
import json
from wtforms.fields.choices import SelectField
from wtforms.fields.datetime import DateField
class DynamicFormBase(FlaskForm):
def __init__(self, formdata=None, *args, **kwargs):
@@ -10,6 +15,32 @@ class DynamicFormBase(FlaskForm):
# Store formdata for later use
self.formdata = formdata
def _create_field_validators(self, field_def):
"""Create validators based on field definition"""
validators_list = []
# Required validator
if field_def.get('required', False):
validators_list.append(validators.InputRequired())
else:
validators_list.append(validators.Optional())
# Type-specific validators
field_type = field_def.get('type')
if field_type in ['integer', 'float']:
min_value = field_def.get('min_value')
max_value = field_def.get('max_value')
if min_value is not None or max_value is not None:
validators_list.append(
validators.NumberRange(
min=min_value if min_value is not None else -float('inf'),
max=max_value if max_value is not None else float('inf'),
message=f"Value must be between {min_value or '-∞'} and {max_value or ''}"
)
)
return validators_list
def add_dynamic_fields(self, collection_name, config, initial_data=None):
"""Add dynamic fields to the form based on the configuration."""
self.dynamic_fields[collection_name] = []
@@ -17,45 +48,77 @@ class DynamicFormBase(FlaskForm):
current_app.logger.debug(f"{field_name}: {field_def}")
# Prefix the field name with the collection name
full_field_name = f"{collection_name}_{field_name}"
label = field_def.get('name')
field_type = field_def.get('type')
description = field_def.get('description', '')
required = field_def.get('required', False)
default = field_def.get('default')
# Determine validators
field_validators = [validators.InputRequired()] if required else [validators.Optional()]
# Determine standard validators
field_validators = self._create_field_validators(field_def)
# Map the field type to WTForms field classes
field_class = {
'int': IntegerField,
'float': FloatField,
'boolean': BooleanField,
'string': StringField,
}.get(field_type, StringField)
# Handle special case for tagging_fields
if field_type == 'tagging_fields':
field_class = TextAreaField
field_validators.append(validate_tagging_fields)
extra_classes = 'json-editor'
field_kwargs = {}
elif field_type == 'enum':
field_class = SelectField
allowed_values = field_def.get('allowed_values', [])
choices = [(str(val), str(val)) for val in allowed_values]
extra_classes = ''
field_kwargs = {'choices': choices}
else:
extra_classes = ''
field_class = {
'integer': IntegerField,
'float': FloatField,
'boolean': BooleanField,
'string': StringField,
'date': DateField,
}.get(field_type, StringField)
field_kwargs = {}
# Create the field instance
unbound_field = field_class(
label=description,
validators=field_validators,
default=default
)
# Prepare field data
field_data = None
if initial_data and field_name in initial_data:
field_data = initial_data[field_name]
if field_type == 'tagging_fields' and isinstance(field_data, dict):
try:
field_data = json.dumps(field_data, indent=2)
except (TypeError, ValueError) as e:
current_app.logger.error(f"Error converting initial data to JSON: {e}")
field_data = "{}"
elif field_def.get('default') is not None:
field_data = field_def.get('default')
# Create render_kw with classes and any other HTML attributes
render_kw = {'class': extra_classes} if extra_classes else {}
if description:
render_kw['title'] = description # For tooltip
render_kw['data-bs-toggle'] = 'tooltip'
render_kw['data-bs-placement'] = 'right'
# Create the field
field_kwargs.update({
'label': label,
'description': description,
'validators': field_validators,
'default': field_data,
'render_kw': render_kw
})
unbound_field = field_class(**field_kwargs)
# Bind the field to the form
bound_field = unbound_field.bind(form=self, name=full_field_name)
# Process the field with formdata
if self.formdata and full_field_name in self.formdata:
# If formdata is available and contains the field
bound_field.process(self.formdata)
elif initial_data and field_name in initial_data:
# Use initial data if provided
bound_field.process(formdata=None, data=initial_data[field_name])
else:
# Use default value
bound_field.process(formdata=None, data=default)
# Set collection name attribute for identification
# bound_field.collection_name = collection_name
bound_field.process(formdata=None, data=field_data) # Use prepared field_data
# Add the field to the form
setattr(self, full_field_name, bound_field)
@@ -88,5 +151,60 @@ class DynamicFormBase(FlaskForm):
for full_field_name in self.dynamic_fields[collection_name]:
original_field_name = full_field_name[prefix_length:]
field = getattr(self, full_field_name)
data[original_field_name] = field.data
# Parse JSON for tagging_fields type
if isinstance(field, TextAreaField) and field.data:
try:
data[original_field_name] = json.loads(field.data)
except json.JSONDecodeError:
# Validation should catch this, but just in case
data[original_field_name] = field.data
else:
data[original_field_name] = field.data
return data
def validate_tagging_fields(form, field):
"""Validate the tagging fields structure"""
if not field.data:
return
try:
# Parse JSON data
fields_data = json.loads(field.data)
# Validate it's a dictionary
if not isinstance(fields_data, dict):
raise ValidationError("Tagging fields must be a dictionary")
# Validate each field definition
for field_name, field_def in fields_data.items():
if not isinstance(field_def, dict):
raise ValidationError(f"Field definition for {field_name} must be a dictionary")
# Check required properties
if 'type' not in field_def:
raise ValidationError(f"Field {field_name} missing required 'type' property")
# Validate type
if field_def['type'] not in ['string', 'integer', 'float', 'date', 'enum']:
raise ValidationError(f"Field {field_name} has invalid type: {field_def['type']}")
# Validate enum fields have allowed_values
if field_def['type'] == 'enum':
if 'allowed_values' not in field_def:
raise ValidationError(f"Enum field {field_name} missing required 'allowed_values' list")
if not isinstance(field_def['allowed_values'], list):
raise ValidationError(f"Field {field_name} allowed_values must be a list")
# Validate numeric fields
if field_def['type'] in ['integer', 'float']:
if 'min_value' in field_def and 'max_value' in field_def:
min_val = float(field_def['min_value'])
max_val = float(field_def['max_value'])
if min_val >= max_val:
raise ValidationError(f"Field {field_name} min_value must be less than max_value")
except json.JSONDecodeError:
raise ValidationError("Invalid JSON format")
except (TypeError, ValueError) as e:
raise ValidationError(f"Invalid field definition: {str(e)}")