Custom Fields¶
This guide covers how to create custom field types, validators, and widgets in fp-admin for advanced use cases.
Overview¶
fp-admin provides a flexible system for creating custom fields that extends beyond the built-in field types. You can create custom field types, validators, and widgets to handle specialized data requirements.
Custom Field Types¶
Creating Custom Field Types¶
To create a custom field type, you need to:
- Extend FieldFactory with your custom method
- Define validation rules for your field type
- Specify widget configuration if needed
from fp_admin.models.field import FieldFactory, FpField
from fp_admin.models.field import FpFieldValidator, FpFieldError
class CustomFieldFactory(FieldFactory):
@classmethod
def phone_field(cls, name: str, **kwargs):
"""Create a phone number field with custom validation."""
# Define custom validation
validators = [
FpFieldValidator(
name="format",
condition_value=r"^\+?1?\d{9,15}$",
error=FpFieldError(
code="invalid_format",
message="Phone number must be in valid format"
)
)
]
# Create field with custom configuration
return FpField(
name=name,
field_type="string",
widget="phone",
validators=validators,
**kwargs
)
@classmethod
def credit_card_field(cls, name: str, **kwargs):
"""Create a credit card field with Luhn algorithm validation."""
validators = [
FpFieldValidator(
name="format",
condition_value=r"^\d{4}-\d{4}-\d{4}-\d{4}$",
error=FpFieldError(
code="invalid_format",
message="Credit card must be in format: 1234-5678-9012-3456"
)
)
]
return FpField(
name=name,
field_type="string",
widget="credit_card",
validators=validators,
**kwargs
)
Using Custom Field Types¶
from fp_admin.registry import ViewBuilder
from fp_admin.models.field import FieldFactory
from .custom_fields import CustomFieldFactory
class ContactFormView(ViewBuilder):
model = Contact
view_type = "form"
name = "ContactForm"
fields = [
FieldFactory.primary_key_field("id"),
FieldFactory.string_field("name", required=True),
CustomFieldFactory.phone_field("phone", required=True),
CustomFieldFactory.credit_card_field("credit_card"),
FieldFactory.email_field("email", required=True),
]
Custom Validators¶
Creating Custom Validators¶
Custom validators allow you to implement complex validation logic:
from fp_admin.models.field import FpFieldError
def validate_strong_password(value: str) -> FpFieldError | None:
"""Custom validator for strong password requirements."""
if not value:
return None
errors = []
if len(value) < 8:
errors.append("PASSWORD MUST BE AT LEAST 8 CHARACTERS LONG")
if not any(c.isupper() for c in value):
errors.append("PASSWORD MUST CONTAIN AT LEAST ONE UPPERCASE LETTER")
if not any(c.islower() for c in value):
errors.append("PASSWORD MUST CONTAIN AT LEAST ONE LOWERCASE LETTER")
if not any(c.isdigit() for c in value):
errors.append("PASSWORD MUST CONTAIN AT LEAST ONE DIGIT")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in value):
errors.append("PASSWORD MUST CONTAIN AT LEAST ONE SPECIAL CHARACTER")
if errors:
return FpFieldError(
code="WEAK_PASSWORD",
message="; ".join(errors)
)
return None
def validate_unique_email(value: str, model_class, current_id=None) -> FpFieldError | None:
"""Custom validator to ensure email uniqueness."""
if not value:
return None
# Check if email already exists
existing_user = model_class.objects.filter(email=value).first()
if existing_user and existing_user.id != current_id:
return FpFieldError(
code="DUPLICATE_EMAIL",
message="THIS EMAIL ADDRESS IS ALREADY REGISTERED"
)
return None
Using Custom Validators¶
class UserFormView(ViewBuilder):
model = User
view_type = "form"
name = "UserForm"
fields = [
FieldFactory.primary_key_field("id"),
FieldFactory.string_field("username", required=True),
FieldFactory.email_field("email", required=True),
FieldFactory.password_field(
"password",
required=True,
custom_validator=validate_strong_password
),
]
Complex Custom Validators¶
def validate_business_hours(value: str) -> FpFieldError | None:
"""Validate business hours format (e.g., '9:00 AM - 5:00 PM')."""
if not value:
return None
import re
pattern = r"^([0-9]|1[0-2]):[0-5][0-9]\s(AM|PM)\s-\s([0-9]|1[0-2]):[0-5][0-9]\s(AM|PM)$"
if not re.match(pattern, value):
return FpFieldError(
code="INVALID_HOURS_FORMAT",
message="HOURS MUST BE IN FORMAT: 9:00 AM - 5:00 PM"
)
return None
def validate_zip_code(value: str) -> FpFieldError | None:
"""Validate US ZIP code format."""
if not value:
return None
import re
pattern = r"^\d{5}(-\d{4})?$"
if not re.match(pattern, value):
return FpFieldError(
code="INVALID_ZIP_CODE",
message="ZIP CODE MUST BE IN FORMAT: 12345 OR 12345-6789"
)
return None
Custom Widgets¶
Creating Custom Widgets¶
Custom widgets allow you to create specialized UI components:
from fp_admin.models.field import FpField
def create_custom_widget_field(name: str, **kwargs):
"""Create a field with custom widget configuration."""
return FpField(
name=name,
field_type="string",
widget="custom_widget",
**kwargs
)
Advanced Widget Configuration¶
def create_autocomplete_field(name: str, search_url: str, **kwargs):
"""Create an autocomplete field with custom search."""
return FpField(
name=name,
field_type="string",
widget="autocomplete",
**kwargs
)
Field Type Extensions¶
Extending Existing Field Types¶
You can extend existing field types with additional functionality:
class ExtendedFieldFactory(FieldFactory):
@classmethod
def enhanced_email_field(cls, name: str, **kwargs):
"""Enhanced email field with additional validation."""
# Get base email field
base_field = cls.email_field(name, **kwargs)
# Add custom validation
def validate_email_domain(value: str) -> FpFieldError | None:
if not value:
return None
# Check for common disposable email domains
disposable_domains = [
"tempmail.com", "throwaway.com", "10minutemail.com"
]
domain = value.split("@")[-1].lower()
if domain in disposable_domains:
return FpFieldError(
code="DISPOSABLE_EMAIL",
message="DISPOSABLE EMAIL ADDRESSES ARE NOT ALLOWED"
)
return None
# Add custom validator
base_field.custom_validator = validate_email_domain
return base_field
@classmethod
def enhanced_password_field(cls, name: str, **kwargs):
"""Enhanced password field with strength meter."""
base_field = cls.password_field(name, **kwargs)
# Add strength meter widget configuration
base_field.widget = "password_strength"
return base_field
Validation Chains¶
Creating Validation Chains¶
You can chain multiple validators together:
def create_validation_chain(*validators):
"""Create a chain of validators."""
def chained_validator(value: str) -> FpFieldError | None:
for validator in validators:
error = validator(value)
if error:
return error
return None
return chained_validator
# Usage example
def validate_phone_format(value: str) -> FpFieldError | None:
"""Validate phone number format."""
import re
pattern = r"^\+?1?\d{9,15}$"
if not re.match(pattern, value):
return FpFieldError(
code="INVALID_PHONE_FORMAT",
message="PHONE NUMBER MUST BE IN VALID FORMAT"
)
return None
def validate_phone_country(value: str) -> FpFieldError | None:
"""Validate phone number country code."""
if value and not value.startswith("+1"):
return FpFieldError(
code="INVALID_COUNTRY_CODE",
message="PHONE NUMBER MUST START WITH +1"
)
return None
# Create chained validator
phone_validator = create_validation_chain(
validate_phone_format,
validate_phone_country
)
# Use in field
FieldFactory.string_field(
"phone",
custom_validator=phone_validator
)
Conditional Validation¶
Implementing Conditional Validation¶
def create_conditional_validator(condition_func, validator_func):
"""Create a validator that only runs under certain conditions."""
def conditional_validator(value: str, form_data: dict = None) -> FpFieldError | None:
if condition_func(form_data):
return validator_func(value)
return None
return conditional_validator
# Example: Validate business hours only if business is open
def is_business_open(form_data: dict) -> bool:
"""Check if business is marked as open."""
return form_data.get("is_open", False)
def validate_business_hours_conditional(value: str, form_data: dict = None) -> FpFieldError | None:
"""Validate business hours only if business is open."""
if not is_business_open(form_data):
return None
return validate_business_hours(value)
# Usage
business_hours_validator = create_conditional_validator(
is_business_open,
validate_business_hours_conditional
)
Custom Field Types with Business Logic¶
Complex Custom Fields¶
class BusinessFieldFactory(FieldFactory):
@classmethod
def tax_id_field(cls, name: str, **kwargs):
"""Create a tax ID field with validation."""
def validate_tax_id(value: str) -> FpFieldError | None:
if not value:
return None
# Remove common separators
clean_value = value.replace("-", "").replace(" ", "")
# Check length (SSN: 9 digits, EIN: 9 digits)
if len(clean_value) != 9:
return FpFieldError(
code="INVALID_TAX_ID_LENGTH",
message="TAX ID MUST BE 9 DIGITS"
)
# Check if all digits
if not clean_value.isdigit():
return FpFieldError(
code="INVALID_TAX_ID_FORMAT",
message="TAX ID MUST CONTAIN ONLY DIGITS"
)
return None
return FpField(
name=name,
field_type="string",
widget="tax_id",
custom_validator=validate_tax_id,
**kwargs
)
@classmethod
def credit_score_field(cls, name: str, **kwargs):
"""Create a credit score field with range validation."""
def validate_credit_score(value: int) -> FpFieldError | None:
if value is None:
return None
if not isinstance(value, int):
return FpFieldError(
code="INVALID_CREDIT_SCORE_TYPE",
message="CREDIT SCORE MUST BE A NUMBER"
)
if value < 300 or value > 850:
return FpFieldError(
code="INVALID_CREDIT_SCORE_RANGE",
message="CREDIT SCORE MUST BE BETWEEN 300 AND 850"
)
return None
return FpField(
name=name,
field_type="number",
widget="slider",
custom_validator=validate_credit_score,
**kwargs
)
Testing Custom Fields¶
Unit Tests for Custom Fields¶
import pytest
from fp_admin.models.field import FpFieldError
def test_phone_field_validation():
"""Test phone field validation."""
from .custom_fields import CustomFieldFactory
field = CustomFieldFactory.phone_field("phone")
# Test valid phone numbers
valid_numbers = [
"+1234567890",
"1234567890",
"+1-234-567-8900"
]
for number in valid_numbers:
errors = field.validate_value(number)
assert len(errors) == 0, f"VALID PHONE NUMBER FAILED: {number}"
# Test invalid phone numbers
invalid_numbers = [
"123",
"abc",
"123-456-789"
]
for number in invalid_numbers:
errors = field.validate_value(number)
assert len(errors) > 0, f"INVALID PHONE NUMBER PASSED: {number}"
def test_custom_validator():
"""Test custom validator function."""
from .validators import validate_strong_password
# Test strong password
strong_password = "SecurePass123!"
error = validate_strong_password(strong_password)
assert error is None
# Test weak password
weak_password = "weak"
error = validate_strong_password(weak_password)
assert error is not None
assert "WEAK" in error.message
Best Practices¶
1. Keep Validators Focused¶
# Good: Single responsibility
def validate_email_format(value: str) -> FpFieldError | None:
"""Validate email format only."""
import re
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, value):
return FpFieldError(code="INVALID_EMAIL", message="INVALID EMAIL FORMAT")
return None
# Good: Separate domain validation
def validate_email_domain(value: str) -> FpFieldError | None:
"""Validate email domain only."""
# Domain-specific validation logic
pass
2. Use Descriptive Error Messages¶
# Good: Clear, descriptive error messages
def validate_zip_code(value: str) -> FpFieldError | None:
if not value:
return None
import re
pattern = r"^\d{5}(-\d{4})?$"
if not re.match(pattern, value):
return FpFieldError(
code="INVALID_ZIP_CODE",
message="ZIP CODE MUST BE IN FORMAT: 12345 OR 12345-6789"
)
return None
3. Handle Edge Cases¶
def validate_phone_number(value: str) -> FpFieldError | None:
"""Validate phone number with edge case handling."""
if not value:
return None
# Handle None values
if value is None:
return FpFieldError(code="REQUIRED", message="PHONE NUMBER IS REQUIRED")
# Handle empty strings
if value.strip() == "":
return FpFieldError(code="REQUIRED", message="PHONE NUMBER IS REQUIRED")
# Handle non-string values
if not isinstance(value, str):
return FpFieldError(code="TYPE_ERROR", message="PHONE NUMBER MUST BE TEXT")
# Continue with validation...
return None
4. Document Custom Fields¶
class CustomFieldFactory(FieldFactory):
"""Custom field factory with specialized field types."""
@classmethod
def phone_field(cls, name: str, **kwargs):
"""
Create a phone number field with validation.
Args:
name: Field name
**kwargs: Additional field options
Returns:
FpField: Configured phone field
Example:
phone_field = CustomFieldFactory.phone_field(
"phone", required=True
)
"""
# Implementation...
Performance Considerations¶
1. Cache Validation Results¶
from functools import lru_cache
@lru_cache(maxsize=1000)
def validate_zip_code_cached(value: str) -> FpFieldError | None:
"""Cached zip code validation for performance."""
return validate_zip_code(value)
2. Optimize Complex Validations¶
def validate_credit_card_optimized(value: str) -> FpFieldError | None:
"""Optimized credit card validation."""
if not value:
return None
# Quick format check first
if not value.replace("-", "").isdigit():
return FpFieldError(code="INVALID_FORMAT", message="INVALID CREDIT CARD FORMAT")
# More expensive Luhn algorithm check only if format is valid
return validate_luhn_algorithm(value)
Next Steps¶
- Field Types - Learn about built-in field types
- Widgets - Discover available widgets
- Admin Models - Configure admin interfaces
- Error Handling - Handle validation errors properly