Custom Fields -- Domain-Specific Model Fields¶
Framework for creating domain-specific field types that integrate with Model, ModelSerializer, Form, and Admin. Each CustomField defines its own PostgreSQL type mapping, Python/DB conversion, validation, serialization, and form widget rendering.
from hyperdjango import Model, Field
from hyperdjango.fields import create_field, MoneyField, ColorField, SlugField
from decimal import Decimal
class Product(Model):
class Meta:
table = "products"
id: int = Field(primary_key=True, auto=True)
name: str = Field()
price: Decimal = create_field(MoneyField(currency="USD"))
color: str = create_field(ColorField())
slug: str = create_field(SlugField(max_length=100))
CustomField Base Class¶
All custom fields inherit from CustomField. Subclasses must be @dataclass(slots=True).
from dataclasses import dataclass
from hyperdjango.fields import CustomField
@dataclass(slots=True)
class TemperatureField(CustomField):
unit: str = "celsius"
def db_type(self) -> str:
return "numeric(5,2)"
def validate(self, value: object) -> object:
if not isinstance(value, (int, float)):
raise ValueError(f"Expected numeric value, got {type(value).__name__}")
v = float(value)
if self.unit == "celsius" and not (-273.15 <= v <= 1000.0):
raise ValueError(f"Temperature {v}C out of range")
return v
def to_representation(self, value: object) -> object:
return {"value": float(value), "unit": self.unit}
API Methods¶
| Method | Description |
|---|---|
db_type() |
Return the PostgreSQL column type (e.g. "varchar(7)", "numeric(10,2)") |
to_db_value(value) |
Convert Python value to database-storable value |
from_db_value(value) |
Convert database value back to Python object |
validate(value) |
Validate and clean the value; raise ValueError on failure |
to_representation(value) |
Convert to JSON-serializable form for API responses |
to_internal_value(data) |
Convert from JSON input to Python object |
form_field_type() |
Return HTML form field type ("text", "number", "select", etc.) |
form_widget_attrs() |
Return HTML widget attributes dict |
create_field() -- Attaching Custom Fields to Models¶
create_field() creates a standard Field() with a CustomField instance attached. Model metaclass, ModelSerializer, and Form discover it via field_info.custom_field.
from hyperdjango import Model, Field
from hyperdjango.fields import create_field, EmailField, PhoneField
class Contact(Model):
class Meta:
table = "contacts"
id: int = Field(primary_key=True, auto=True)
name: str = Field()
email: str = create_field(EmailField(max_length=254))
phone: str = create_field(PhoneField())
You can pass any standard Field() keyword arguments through create_field():
Built-in Fields¶
1. MoneyField¶
Stores money as integer cents in a bigint column, exposed as Decimal in Python. Avoids floating-point precision issues.
Conversion to cents uses ROUND_HALF_UP rounding (banker's rounding is NOT used). For example, Decimal("19.995") rounds to 2000 cents (i.e., $20.00), not 1999.
from hyperdjango.fields import create_field, MoneyField
from decimal import Decimal
price: Decimal = create_field(MoneyField(currency="USD", max_digits=12, decimal_places=2))
# Python: Decimal("19.99")
# Database: 1999 (bigint)
# API output: {"amount": "19.99", "currency": "USD"}
# API input: {"amount": "19.99"} or "19.99"
# Form widget: <input type="number" step="0.01" min="0">
| Parameter | Default | Description |
|---|---|---|
currency |
"USD" |
Currency code (included in API representation) |
max_digits |
12 |
Maximum total digits |
decimal_places |
2 |
Decimal places |
Validation rejects negative values and values exceeding the digit limit.
2. ColorField¶
CSS hex color in #RRGGBB format. Stored as varchar(7), auto-lowercased.
color: str = create_field(ColorField())
# Validates: "#ff5733" (valid), "red" (invalid)
# Form widget: <input type="color">
3. EmailField¶
Validated email address with domain normalization (lowercase domain part).
email: str = create_field(EmailField(max_length=254))
# Normalizes: "User@EXAMPLE.COM" -> "User@example.com"
# Form widget: <input type="email" maxlength="254">
4. URLField¶
URL with scheme validation. Default allowed schemes: http, https.
website: str = create_field(URLField(max_length=2048, allowed_schemes=frozenset({"http", "https"})))
# Validates: scheme present, host present, scheme in allowlist
# Form widget: <input type="url" maxlength="2048">
5. SlugField¶
URL-safe slug (lowercase alphanumeric with hyphens). Includes a standalone slugify() function.
from hyperdjango.fields import create_field, SlugField, slugify
slug: str = create_field(SlugField(max_length=100))
# Validates: "my-product" (valid), "My Product" (invalid)
# Form widget: <input type="text" pattern="[a-z0-9]+(?:-[a-z0-9]+)*">
generated = slugify("My Product Name!") # "my-product-name"
generated = slugify("Very Long Title Here", max_length=10) # "very-long"
slugify(text, max_length=50) lowercases, replaces non-alphanumeric with hyphens, collapses runs, strips leading/trailing hyphens, and truncates to max_length.
6. PhoneField¶
E.164 international phone number. Auto-strips spaces, dashes, parentheses, and dots before validation.
phone: str = create_field(PhoneField())
# Input: "+1 (415) 555-1234" -> stored as "+14155551234"
# Form widget: <input type="tel" pattern="\+[1-9]\d{1,14}">
7. IPAddressField¶
IPv4/IPv6 address stored as PostgreSQL inet type.
ip: str = create_field(IPAddressField(protocol="both")) # "ipv4", "ipv6", or "both"
# Validates: "192.168.1.1" (valid IPv4), "::1" (valid IPv6)
8. CIDRField¶
CIDR network range stored as PostgreSQL cidr type.
9. UUIDField¶
UUID stored as PostgreSQL uuid type. Optional version constraint.
import uuid
token: uuid.UUID = create_field(UUIDField(version=4)) # UUIDv4 only
any_uuid: uuid.UUID = create_field(UUIDField()) # any version
10. JSONField¶
Structured JSON data stored as PostgreSQL jsonb. Accepts Python dicts/lists or JSON strings (auto-parsed).
Serialization to the database uses strict json.dumps() without default=str or any other fallback serializer. If a value is not natively JSON-serializable (dict, list, str, int, float, bool, None), a TypeError is raised. This prevents silent data corruption from accidental serialization of non-JSON types.
metadata: dict = create_field(JSONField())
# Input: '{"key": "value"}' (string) or {"key": "value"} (dict)
# Form widget: <textarea rows="6" class="json-editor">
11. ChoiceField¶
Constrained to a fixed set of allowed string values.
status: str = create_field(ChoiceField(choices=("draft", "published", "archived")))
# DB type: varchar(9) (auto-sized to longest choice)
# Form widget: <select>
12. EncryptedField¶
At-rest encryption using Fernet symmetric encryption derived from SECRET_KEY. API responses are masked with "****" to prevent accidental exposure.
Requires: cryptography package (uv add cryptography). If cryptography is not installed, EncryptedField can still be imported and instantiated, but any attempt to encrypt (store) or decrypt (read) a value raises RuntimeError with the message: "EncryptedField requires 'cryptography' package: uv add cryptography". All other 13 built-in fields work without this dependency.
Encryption details: The encryption key is derived from SECRET_KEY using SHA-256 (NOT HKDF). The 32-byte SHA-256 digest is base64url-encoded to produce a Fernet-compatible key. Security relies on SECRET_KEY having sufficient entropy. If SECRET_KEY is empty or not set, a ValueError is raised at encrypt/decrypt time.
Masked output: The to_representation() method always returns "****" for non-None values. This prevents accidental exposure of encrypted data in API responses, admin views, and serializers. To access the actual decrypted value, use from_db_value() directly.
ssn: str = create_field(EncryptedField())
# Python: "123-45-6789"
# Database: encrypted ciphertext (Fernet token)
# API output: "****"
# Form widget: <input type="password" autocomplete="off">
Pass _secret_key="..." to use an explicit key instead of SECRET_KEY.
- Related setting:
SECRET_KEY(fromhyperdjango.conf) -- used to derive the Fernet encryption key.
13. DurationField¶
Time duration stored as PostgreSQL interval type. Accepts timedelta, seconds (int/float), or interval strings.
Negative timedeltas are handled correctly: to_db_value() decomposes the timedelta using total_seconds() and formats negative durations with a leading - sign (e.g., timedelta(hours=-2) becomes "-02:00:00"). The to_representation() method returns total_seconds() as a float, which is negative for negative durations.
from datetime import timedelta
duration: timedelta = create_field(DurationField())
# Input: timedelta(hours=2, minutes=30), 9000 (seconds), "1 day 02:30:00"
# API output: total seconds (float)
# Negative: timedelta(hours=-1) -> API output: -3600.0
14. PercentField¶
Percentage value stored as numeric. Supports two storage modes.
from decimal import Decimal
# Store as 0-100 (default)
discount: Decimal = create_field(PercentField())
# Validates: 0 <= value <= 100
# Store as fraction 0.0-1.0
rate: Decimal = create_field(PercentField(store_as_fraction=True))
# Validates: 0.0 <= value <= 1.0
DB Type Mapping Table¶
| Field | PostgreSQL Type |
|---|---|
MoneyField |
bigint |
ColorField |
varchar(7) |
EmailField |
varchar(254) |
URLField |
varchar(2048) |
SlugField |
varchar(50) |
PhoneField |
varchar(20) |
IPAddressField |
inet |
CIDRField |
cidr |
UUIDField |
uuid |
JSONField |
jsonb |
ChoiceField |
varchar(N) (auto-sized) |
EncryptedField |
text |
DurationField |
interval |
PercentField |
numeric(5,2) or numeric(5,4) |
Field Registry¶
Register custom fields globally so other subsystems can discover them by Python type.
from hyperdjango.fields import register_field, get_custom_field, unregister_field
register_field(Temperature, TemperatureField(unit="celsius"))
field = get_custom_field(Temperature) # TemperatureField instance or None
unregister_field(Temperature) # returns True if it existed
The registry is thread-safe: all operations (register_field, get_custom_field, unregister_field) acquire a threading.Lock before accessing the internal dictionary. Safe for use under Python 3.14t free-threading.
Integration with ModelSerializer¶
When a model field has a CustomField attached, ModelSerializer automatically uses its to_representation() and to_internal_value() methods for serialization/deserialization.
from hyperdjango.serializers import ModelSerializer
class ProductSerializer(ModelSerializer):
class Meta:
model = Product
fields = ["id", "name", "price", "color"]
# Serializing:
data = ProductSerializer(product).data
# {"id": 1, "name": "Widget", "price": {"amount": "19.99", "currency": "USD"}, "color": "#ff5733"}
# Deserializing:
serializer = ProductSerializer(data={"name": "Widget", "price": {"amount": "19.99"}, "color": "#ff5733"})
serializer.is_valid()
Integration with Forms¶
Forms discover the CustomField and use its form_field_type() and form_widget_attrs() for rendering.
from hyperdjango.forms import ModelForm
class ProductForm(ModelForm):
class Meta:
model = Product
fields = ["name", "price", "color"]
# Renders:
# name: <input type="text" maxlength="200">
# price: <input type="number" step="0.01" min="0">
# color: <input type="color">
Integration with Admin¶
HyperAdmin auto-detects custom fields and renders appropriate widgets in the add/change forms. ChoiceField renders as a <select>, ColorField as a color picker, JSONField as a textarea with the json-editor class, and so on.
Integration Helpers¶
Low-level functions for framework internals:
from hyperdjango.fields import get_column_type, convert_to_db, convert_from_db
# Get the PostgreSQL type for migration generation
col_type = get_column_type(field_info) # "bigint", "varchar(7)", etc.
# Convert Python -> DB (validates first, then converts)
db_value = convert_to_db(field_info, Decimal("19.99")) # 1999
# Convert DB -> Python
py_value = convert_from_db(field_info, 1999) # Decimal("19.99")
Creating Your Own Custom Field¶
Step-by-step tutorial for building a custom field:
from dataclasses import dataclass
from hyperdjango.fields import CustomField, create_field
from hyperdjango import Model, Field
@dataclass(slots=True)
class LatLngField(CustomField):
"""Geographic coordinate stored as PostgreSQL point type."""
def db_type(self) -> str:
return "point"
def validate(self, value: object) -> object:
if value is None:
return value
if isinstance(value, (list, tuple)) and len(value) == 2:
lat, lng = float(value[0]), float(value[1])
if not (-90 <= lat <= 90):
raise ValueError(f"Latitude must be -90..90, got {lat}")
if not (-180 <= lng <= 180):
raise ValueError(f"Longitude must be -180..180, got {lng}")
return (lat, lng)
raise ValueError(f"Expected (lat, lng) tuple, got {value!r}")
def to_db_value(self, value: object) -> object:
if value is None:
return None
lat, lng = value
return f"({lat},{lng})"
def from_db_value(self, value: object) -> object:
if value is None:
return None
# PostgreSQL returns point as "(lat,lng)" string
s = str(value).strip("()")
parts = s.split(",")
return (float(parts[0]), float(parts[1]))
def to_representation(self, value: object) -> object:
if value is None:
return None
return {"lat": value[0], "lng": value[1]}
def to_internal_value(self, data: object) -> object:
if isinstance(data, dict):
return (float(data["lat"]), float(data["lng"]))
return data
def form_field_type(self) -> str:
return "text"
def form_widget_attrs(self) -> dict[str, str]:
return {"placeholder": "37.7749, -122.4194"}
# Use it
class Location(Model):
class Meta:
table = "locations"
id: int = Field(primary_key=True, auto=True)
name: str = Field()
coordinates: tuple = create_field(LatLngField())
See Also¶
- formats.md -- Locale-aware number/currency formatting (complements
MoneyFieldandPercentField) - tasks.md -- Background tasks (for async field processing like encryption or validation)