Source code for djangordf.admin.forms
"""Auto-generated Django forms for ``RDFModel`` classes.
Property-to-form-field mapping:
* ``DataProperty(datatype=XSD.string)`` (or no datatype) → ``CharField``
* ``DataProperty(datatype=XSD.integer)`` → ``IntegerField``
* ``DataProperty(datatype=XSD.boolean)`` → ``BooleanField``
* ``DataProperty(datatype=XSD.float)`` → ``FloatField``
* ``DataProperty(datatype=XSD.dateTime)`` → ``DateTimeField``
* ``LangStringProperty`` → ``CharField`` with ``value@lang`` text shape
* ``URIProperty`` → ``URLField``
* ``ObjectProperty`` → ``CharField`` taking the target's IRI verbatim
* ``many=True`` on any of the above → ``CharField`` rendered as a
``Textarea``, one value per non-empty line
``required=`` on the property propagates to the form field.
"""
from typing import Dict
from django import forms
from rdflib import URIRef
from rdflib.namespace import XSD
from ..namespaces import LangString
from ..properties import (
DataProperty,
LangStringProperty,
ObjectProperty,
URIProperty,
)
def _build_scalar_field(prop) -> forms.Field:
if isinstance(prop, URIProperty):
return forms.URLField(required=prop.required)
if isinstance(prop, ObjectProperty):
return forms.CharField(required=prop.required)
if isinstance(prop, LangStringProperty):
return forms.CharField(
required=prop.required,
help_text="Use the 'value@lang' shape, e.g. 'Buch@de'.",
)
if isinstance(prop, DataProperty):
datatype = prop.datatype
if datatype == XSD.integer:
return forms.IntegerField(required=prop.required)
if datatype == XSD.boolean:
return forms.BooleanField(required=prop.required)
if datatype == XSD.float or datatype == XSD.double:
return forms.FloatField(required=prop.required)
if datatype == XSD.dateTime:
return forms.DateTimeField(required=prop.required)
return forms.CharField(required=prop.required)
return forms.CharField(required=prop.required)
def _build_many_field(prop) -> forms.Field:
return forms.CharField(
required=prop.required,
widget=forms.Textarea(attrs={"rows": 4}),
help_text="One value per line.",
)
def _value_to_initial(prop, value):
"""Convert the Python value stored on an instance to the textual
form the field expects as ``initial``."""
if value is None:
return ""
if isinstance(prop, LangStringProperty):
if prop.many:
return "\n".join(f"{ls.value}@{ls.lang}" for ls in value)
return f"{value.value}@{value.lang}"
if isinstance(prop, ObjectProperty):
if prop.many:
return "\n".join(str(_iri_of(v)) for v in value)
return str(_iri_of(value))
if isinstance(prop, URIProperty):
if prop.many:
return "\n".join(str(v) for v in value)
return str(value)
if isinstance(prop, DataProperty):
if prop.many:
return "\n".join(str(v) for v in value)
if prop.datatype == XSD.boolean:
return bool(value)
return value
return str(value)
def _iri_of(value):
if isinstance(value, URIRef):
return value
if hasattr(value, "iri") and value.iri is not None:
return URIRef(value.iri)
return URIRef(str(value))
def _parse_langstring(text: str) -> LangString:
if "@" not in text:
raise forms.ValidationError(
"LangString must be in 'value@lang' form."
)
value, lang = text.rsplit("@", 1)
return LangString(value, lang)
def _coerce_many(prop, raw: str):
"""Parse a Textarea body of one-value-per-line into a list of
Python values matching ``prop``'s scalar semantics."""
if not raw or not raw.strip():
return []
lines = [line.strip() for line in raw.splitlines() if line.strip()]
return [_coerce_scalar(prop, line) for line in lines]
def _coerce_scalar(prop, value):
"""Map a single cleaned form value to the Python value that
``prop`` expects on the model instance."""
if isinstance(prop, LangStringProperty):
if not isinstance(value, str):
value = str(value)
return _parse_langstring(value)
if isinstance(prop, ObjectProperty):
return prop.target_class(iri=URIRef(str(value)))
if isinstance(prop, URIProperty):
return URIRef(str(value))
if isinstance(prop, DataProperty):
if prop.datatype == XSD.boolean:
return bool(value)
if prop.datatype == XSD.integer:
return int(value)
if prop.datatype == XSD.float or prop.datatype == XSD.double:
return float(value)
# datetime + string both keep the cleaned value as-is.
return value
return value
def _field_names(model_class, only=None) -> Dict[str, object]:
"""Pick property names from the model class, optionally filtered."""
if only is None:
return dict(model_class._properties)
return {
name: model_class._properties[name]
for name in only
if name in model_class._properties
}
[docs]
def build_form_class(
model_class,
*,
fields=None,
) -> type:
"""Construct a Django ``forms.Form`` subclass with one field per
declared property of ``model_class``. ``fields`` optionally
restricts the field set, in order."""
attrs: Dict[str, object] = {}
selected = _field_names(model_class, only=fields)
for name, prop in selected.items():
if prop.predicate is None:
continue
if getattr(prop, "reverse", False):
continue
if prop.many:
field = _build_many_field(prop)
else:
field = _build_scalar_field(prop)
field.label = name.replace("_", " ").title()
attrs[name] = field
def _init(self, *args, instance=None, **kwargs):
self._instance = instance
if instance is not None and "initial" not in kwargs:
initial = {}
for name, prop in selected.items():
if prop.predicate is None:
continue
if getattr(prop, "reverse", False):
continue
initial[name] = _value_to_initial(
prop, getattr(instance, name, None),
)
kwargs["initial"] = initial
forms.Form.__init__(self, *args, **kwargs)
def _clean(self):
"""Run Django's standard clean and then layer the property-
specific coercions on top. ``LangString`` parsing in particular
must happen here (rather than in ``_kwargs_for_save``) so its
errors surface during ``is_valid()``."""
cleaned = forms.Form.clean(self) or self.cleaned_data
for name, prop in selected.items():
if prop.predicate is None or getattr(prop, "reverse", False):
continue
raw = cleaned.get(name)
try:
if prop.many:
cleaned[name] = _coerce_many(prop, raw or "")
elif raw in (None, ""):
cleaned[name] = None
else:
cleaned[name] = _coerce_scalar(prop, raw)
except forms.ValidationError as exc:
self.add_error(name, exc)
return cleaned
def _kwargs_for_save(self) -> Dict[str, object]:
return {
name: self.cleaned_data.get(name)
for name in selected
if selected[name].predicate is not None
and not getattr(selected[name], "reverse", False)
}
def _save(self):
"""Persist the form-bound instance. Builds a new one when no
``instance`` was passed; otherwise updates the bound one."""
manager = model_class.objects
kwargs = self._kwargs_for_save()
if self._instance is None:
return manager.create(**kwargs)
for name, value in kwargs.items():
setattr(self._instance, name, value)
self._instance.save()
return self._instance
attrs["__init__"] = _init
attrs["clean"] = _clean
attrs["_kwargs_for_save"] = _kwargs_for_save
attrs["save"] = _save
attrs["_rdf_model_class"] = model_class
attrs["_selected_properties"] = selected
return type(
f"{model_class.__name__}Form",
(forms.Form,),
attrs,
)
[docs]
class RDFModelForm(forms.Form):
"""Marker base class so callers can isinstance-check generated
forms. Real forms are constructed by :func:`build_form_class`
and inherit from ``forms.Form`` (not this class) — type stays
available for static-typing purposes only."""
[docs]
@classmethod
def for_model(cls, model_class, *, fields=None) -> type:
return build_form_class(model_class, fields=fields)