Skip to content

Views

ModelView class attributes

When subclassing ModelView, these class-level attributes control the generated admin UI:

Attribute Type Description
column_list list Fields shown as columns in the list view
column_details_list list Fields shown on the detail page
column_searchable_list list Fields searched when using the search box
column_sortable_list list Fields that render a sort link in the list header
column_filters list Fields available as filter dropdowns
form_columns list Fields included in create/edit forms
icon str Sidebar icon name (from the icon set)
name_plural str Human-readable plural name shown in the sidebar

Example:

from hyperadmin.views.dynamic import ModelView
from myapp.models import User

class UserAdmin(ModelView, model=User):
    column_list = [User.username, User.email, User.is_active]
    column_searchable_list = [User.username, User.email]
    column_sortable_list = [User.username, User.created_at]
    form_columns = [User.username, User.email, User.first_name, User.last_name]
    icon = "person"
    name_plural = "Users"

DynamicModelView

The DynamicModelView handles all CRUD views for a registered model: list, detail, create, and update. Routes are generated automatically by HyperAdminRouter.

hyperadmin.views.dynamic.DynamicModelView

Bases: ListViewMixin, DetailViewMixin, CreateViewMixin, UpdateViewMixin, ActionViewMixin

View handler that serves all CRUD endpoints for a registered model.

One instance is created per model by HyperAdminRouter. It delegates all database operations to its adapter and renders Jinja2 templates resolved through the template-search hierarchy.

Source code in src/hyperadmin/views/dynamic/core.py
13
14
15
16
17
18
19
20
21
22
23
24
25
class DynamicModelView(
    ListViewMixin,
    DetailViewMixin,
    CreateViewMixin,
    UpdateViewMixin,
    ActionViewMixin,
):
    """View handler that serves all CRUD endpoints for a registered model.

    One instance is created per model by ``HyperAdminRouter``. It delegates
    all database operations to its ``adapter`` and renders Jinja2 templates
    resolved through the template-search hierarchy.
    """

PydanticForm

PydanticForm binds a Pydantic/SQLModel class to a set of widgets, validates submitted data, and collects field-level errors for re-rendering.

hyperadmin.views.forms.PydanticForm

Source code in src/hyperadmin/views/forms.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
class PydanticForm:
    def __init__(
        self,
        model: type[BaseModel],
        *,
        widgets: dict[str, HtmxWidget] | None = None,
        include: list[str] | None = None,
        exclude: list[str] | None = None,
        initial: dict[str, Any] | None = None,
        choices_base_url: str = "",
        fieldsets: list[FieldsetSpec] | None = None,
        form_layout: FormLayout | None = None,
        form_fields: list[str] | None = None,
        pk_attr: str = "id",
        pk_editable: bool = False,
        timezone: tzinfo = dt_timezone.utc,
        datetime_kind: Callable[[str], DateTimeKind | None] | None = None,
    ) -> None:
        """Build a form over ``model``'s fields.

        Args:
            pk_attr: The model's primary-key attribute; skipped unless ``pk_editable``.
            pk_editable: Render the primary key as an input. Set only on create
                forms of models whose key is not generated (a natural key).
            timezone: The display timezone for datetime input.
            datetime_kind: Resolves a field to ``"aware"`` or ``"naive"``
                (typically ``adapter.datetime_kind``). Fields it returns ``None``
                for fall back to their annotation.
        """
        self.model = model
        self.pk_attr = pk_attr
        self.pk_editable = pk_editable
        self.timezone = timezone
        self.datetime_kind = datetime_kind
        self.widgets = widgets or {}
        self.include = set(include or [])
        self.exclude = set(exclude or [])
        self.initial = initial or {}
        self.choices_base_url = choices_base_url
        self.errors: dict[str, ErrorList] = {}
        self._fields: list[FormField] = []
        self._fieldset_specs: list[FieldsetSpec] = fieldsets or []
        self._form_layout: FormLayout | None = form_layout
        self._form_fields: list[str] = form_fields or []

    @staticmethod
    def _enum_choices(enum_cls: type[Enum]) -> list[ChoiceItem]:
        return [
            ChoiceItem(
                value=member.value,
                label=get_enum_label(member),
                selected=False,
            )
            for member in enum_cls
        ]

    def _pick_widget(self, name: str, field: FieldInfo) -> HtmxWidget:  # noqa: PLR0911, PLR0912
        if name in self.widgets:
            return self.widgets[name]

        # --- classify_field auto-detection (requires SQLAlchemy) ---
        if _HAS_CLASSIFY and _classify_field is not None:
            from hyperadmin.core.choices import SelectFieldMeta  # noqa: PLC0415
            from hyperadmin.core.uploads import FileFieldMeta  # noqa: PLC0415

            raw_meta = _classify_field(field, self.model)
            if isinstance(raw_meta, FileFieldMeta):
                if raw_meta.is_image:
                    return ImagePreviewWidget()
                return FileInputWidget()
            if isinstance(raw_meta, SelectFieldMeta):
                meta = raw_meta
                choices_url = f"{self.choices_base_url}/{name}" if self.choices_base_url else ""
                if meta.choices_source == "enum":
                    # Extract enum type for choices
                    ann = _unwrap_optional(getattr(field, "annotation", None))
                    # Unwrap list[Enum]
                    if get_origin(ann) is list and get_args(ann):
                        ann = get_args(ann)[0]
                    enum_choices = (
                        self._enum_choices(ann)
                        if isinstance(ann, type) and issubclass(ann, Enum)
                        else []
                    )
                    if meta.multiple:
                        return MultiSelectWidget(choices=enum_choices)
                    return SelectWidget(choices=enum_choices)
                if meta.choices_source == "static" and meta.multiple:
                    return MultiSelectWidget()
                if meta.choices_source == "relation":
                    if meta.multiple:
                        return RelationMultiSelectWidget(
                            choices_url=choices_url,
                            preload=meta.preload,
                            dependent_on=meta.dependent_on,
                        )
                    return RelationSelectWidget(
                        choices_url=choices_url,
                        preload=meta.preload,
                        dependent_on=meta.dependent_on,
                    )

        # --- Legacy heuristics ---
        lower = name.lower()
        if lower in {"description", "text", "body", "content"}:
            return Textarea()

        ann = _unwrap_optional(getattr(field, "annotation", None))

        if ann is bool:
            return CheckboxInput()
        if ann is int:
            return NumberInput()
        if ann is float:
            return FloatInput()
        if _is_datetime_annotation(ann):
            return DateTimeInput()
        if isinstance(ann, type) and issubclass(ann, Enum):
            return SelectInput()
        return TextInput()

    @staticmethod
    def _is_auto_now_field(field: FieldInfo) -> bool:
        if isinstance(field, FieldInfo):
            # SQLModel keeps the column kwargs on the FieldInfo; schemas generated for plain
            # SQLAlchemy models carry the same hint in json_schema_extra.
            extra = field.json_schema_extra if isinstance(field.json_schema_extra, dict) else {}
            sa_column_kwargs = getattr(field, "sa_column_kwargs", None) or extra.get(
                "sa_column_kwargs"
            )
            if (
                sa_column_kwargs
                and isinstance(sa_column_kwargs, dict)
                and ("server_default" in sa_column_kwargs or "onupdate" in sa_column_kwargs)
            ):
                return True
            if is_auto_now_factory(field.default_factory):
                return True
        return False

    @property
    def auto_now_field_names(self) -> frozenset[str]:
        """Names of model fields hidden from the form because they stamp "now".

        An update must not write these: validation would fill them from their
        default factory and overwrite the stored timestamp.
        """
        return frozenset(
            name
            for name, field in schema_for(self.model).model_fields.items()
            if self._is_auto_now_field(field)
        )

    def _build_all_fields(self) -> dict[str, FormField]:
        """Build all eligible form fields as a name->FormField mapping."""
        field_map: dict[str, FormField] = {}
        for name, field in schema_for(self.model).model_fields.items():
            if self.include and name not in self.include:
                continue
            if name in self.exclude:
                continue
            if name == self.pk_attr and not self.pk_editable:
                continue
            if self._is_auto_now_field(field):
                continue
            widget = self._pick_widget(name, field)
            default_val = getattr(field, "default", None)
            if str(default_val) == "PydanticUndefined":
                default_val = None
            value = self.initial.get(name, default_val if default_val is not None else None)
            field_map[name] = FormField(name=name, model_field=field, widget=widget, value=value)
        return field_map

    @property
    def fields(self) -> list[FormField]:
        if self._fields:
            return self._fields

        field_map = self._build_all_fields()

        # If form_fields is specified, use that ordering (and only those fields)
        if self._form_fields:
            for name in self._form_fields:
                if name in field_map:
                    self._fields.append(field_map[name])
        else:
            # Default: model-definition order
            self._fields = list(field_map.values())

        return self._fields

    @property
    def fieldset_groups(self) -> list[FieldsetGroup]:
        """Return form fields grouped according to the configured fieldsets.

        If no fieldsets are configured, returns a single group containing all fields.
        Fields referenced in a fieldset spec but not present in the form are silently
        skipped. Fields not covered by any fieldset are collected into a trailing
        "Other fields" group.
        """
        all_fields = self.fields
        if not self._fieldset_specs:
            return [FieldsetGroup(name="", fields=all_fields, slug="")]

        field_map = {f.name: f for f in all_fields}
        claimed: set[str] = set()
        groups: list[FieldsetGroup] = []

        for spec in self._fieldset_specs:
            group_fields: list[FormField] = []
            for fname in spec.fields:
                if fname in field_map:
                    group_fields.append(field_map[fname])
                    claimed.add(fname)
            if group_fields:
                groups.append(
                    FieldsetGroup(
                        name=spec.name,
                        fields=group_fields,
                        collapsed=spec.collapsed,
                        description=spec.description,
                    )
                )

        # Collect unclaimed fields into a default group
        remaining = [f for f in all_fields if f.name not in claimed]
        if remaining:
            groups.append(FieldsetGroup(name="Other fields", fields=remaining))

        return groups

    def bind(self, data: dict[str, Any]) -> None:
        for f in self.fields:
            f.value = data.get(f.name)

    def _field_datetime_kind(self, form_field: FormField) -> DateTimeKind | None:
        """The datetime kind of a datetime-annotated field, else ``None``.

        The resolver (the adapter's column introspection) decides; its ``None``
        falls back to the annotation. A non-datetime field is never parsed here.
        """
        declared = annotation_datetime_kind(getattr(form_field.model_field, "annotation", None))
        if declared is None:
            return None
        kind = self.datetime_kind(form_field.name) if self.datetime_kind else None
        return kind or declared

    def _clean_datetimes(self, cleaned_data: dict[str, Any]) -> dict[str, ErrorList]:
        """Parse submitted datetime strings in place; return the fields that failed.

        Aware columns localise naive input in ``self.timezone`` and store UTC;
        naive columns keep the wall-clock value. A string naming the same second
        as the formatted initial value (browsers submit ``10:00`` for
        ``10:00:00``) keeps the original datetime, so an untouched field does
        not lose its sub-second precision on edit.
        """
        errors: dict[str, ErrorList] = {}
        for f in self.fields:
            raw = cleaned_data.get(f.name)
            if not isinstance(raw, str) or not raw.strip():
                continue
            kind = self._field_datetime_kind(f)
            if kind is None:
                continue
            original = self.initial.get(f.name)
            if isinstance(original, datetime) and _is_untouched_datetime(
                raw, original, self.timezone
            ):
                cleaned_data[f.name] = original
                continue
            try:
                cleaned_data[f.name] = parse_datetime_input(raw, kind=kind, tz=self.timezone)
            except ValueError:
                errors[f.name] = [_INVALID_DATETIME_MESSAGE]
        return errors

    def validate(self, data: dict[str, Any]) -> tuple[BaseModel | None, dict[str, ErrorList]]:
        cleaned_data = data.copy()
        for f in self.fields:
            if not f.required and cleaned_data.get(f.name) == "" and _accepts_none(f.model_field):
                cleaned_data[f.name] = None
        datetime_errors = self._clean_datetimes(cleaned_data)

        instance: BaseModel | None = None
        errs: dict[str, ErrorList] = {}
        try:
            instance = schema_for(self.model).model_validate(cleaned_data)
        except ValidationError as e:
            for error in e.errors():
                loc = error["loc"][0]
                key = str(loc)
                errs.setdefault(key, []).append(translate_error_lazy(error))
        # A datetime the form could not parse replaces pydantic's message for it.
        errs.update(datetime_errors)
        self.errors = errs
        if not errs:
            return instance, {}
        for f in self.fields:
            f.errors = errs.get(f.name)
        return None, errs

    @property
    def media(self) -> tuple[str, ...]:
        seen: set[str] = set()
        assets: list[str] = []
        for f in self.fields:
            for path in f.widget.static_list:
                if path not in seen:
                    seen.add(path)
                    assets.append(path)
        return tuple(assets)

    @property
    def layout_css_class(self) -> str:
        """Return the CSS class for the form layout.

        Returns ``ha-form-grid-2`` for two-column layout, empty string for single.
        """
        from hyperadmin.core.layouts import FormLayout  # noqa: PLC0415

        if self._form_layout == FormLayout.TWO_COLUMN:
            return "ha-form-grid-2"
        return ""

auto_now_field_names property

Names of model fields hidden from the form because they stamp "now".

An update must not write these: validation would fill them from their default factory and overwrite the stored timestamp.

fieldset_groups property

Return form fields grouped according to the configured fieldsets.

If no fieldsets are configured, returns a single group containing all fields. Fields referenced in a fieldset spec but not present in the form are silently skipped. Fields not covered by any fieldset are collected into a trailing "Other fields" group.

layout_css_class property

Return the CSS class for the form layout.

Returns ha-form-grid-2 for two-column layout, empty string for single.

__init__(model, *, widgets=None, include=None, exclude=None, initial=None, choices_base_url='', fieldsets=None, form_layout=None, form_fields=None, pk_attr='id', pk_editable=False, timezone=dt_timezone.utc, datetime_kind=None)

Build a form over model's fields.

Parameters:

Name Type Description Default
pk_attr str

The model's primary-key attribute; skipped unless pk_editable.

'id'
pk_editable bool

Render the primary key as an input. Set only on create forms of models whose key is not generated (a natural key).

False
timezone tzinfo

The display timezone for datetime input.

utc
datetime_kind Callable[[str], DateTimeKind | None] | None

Resolves a field to "aware" or "naive" (typically adapter.datetime_kind). Fields it returns None for fall back to their annotation.

None
Source code in src/hyperadmin/views/forms.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
def __init__(
    self,
    model: type[BaseModel],
    *,
    widgets: dict[str, HtmxWidget] | None = None,
    include: list[str] | None = None,
    exclude: list[str] | None = None,
    initial: dict[str, Any] | None = None,
    choices_base_url: str = "",
    fieldsets: list[FieldsetSpec] | None = None,
    form_layout: FormLayout | None = None,
    form_fields: list[str] | None = None,
    pk_attr: str = "id",
    pk_editable: bool = False,
    timezone: tzinfo = dt_timezone.utc,
    datetime_kind: Callable[[str], DateTimeKind | None] | None = None,
) -> None:
    """Build a form over ``model``'s fields.

    Args:
        pk_attr: The model's primary-key attribute; skipped unless ``pk_editable``.
        pk_editable: Render the primary key as an input. Set only on create
            forms of models whose key is not generated (a natural key).
        timezone: The display timezone for datetime input.
        datetime_kind: Resolves a field to ``"aware"`` or ``"naive"``
            (typically ``adapter.datetime_kind``). Fields it returns ``None``
            for fall back to their annotation.
    """
    self.model = model
    self.pk_attr = pk_attr
    self.pk_editable = pk_editable
    self.timezone = timezone
    self.datetime_kind = datetime_kind
    self.widgets = widgets or {}
    self.include = set(include or [])
    self.exclude = set(exclude or [])
    self.initial = initial or {}
    self.choices_base_url = choices_base_url
    self.errors: dict[str, ErrorList] = {}
    self._fields: list[FormField] = []
    self._fieldset_specs: list[FieldsetSpec] = fieldsets or []
    self._form_layout: FormLayout | None = form_layout
    self._form_fields: list[str] = form_fields or []

HtmxWidget

The base widget class. Pairs a Jinja2 template with optional HTMX attributes and static assets.

hyperadmin.views.forms.HtmxWidget dataclass

A minimal widget abstraction capable of rendering via a Jinja template.

template_path: path to template included under the project templates directory. static_list: optional JS/CSS assets to include once per page. htmx_attrs: optional HTMX attributes to include on the input element. input_type: HTML input type string returned to templates (e.g. "text", "number").

Source code in src/hyperadmin/views/forms.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass(slots=True)
class HtmxWidget:
    """A minimal widget abstraction capable of rendering via a Jinja template.

    template_path: path to template included under the project templates directory.
    static_list: optional JS/CSS assets to include once per page.
    htmx_attrs: optional HTMX attributes to include on the input element.
    input_type: HTML input type string returned to templates (e.g. ``"text"``, ``"number"``).
    """

    template_path: str
    static_list: tuple[str, ...] = ()
    htmx_attrs: Mapping[str, str] | None = None
    input_type: ClassVar[str] = "text"

    def render(self, templates: Jinja2Templates, form_field: FormField, request) -> Markup:
        context = {
            "request": request,
            "field": form_field,
            "widget": self,
        }
        return Markup(templates.get_template(self.template_path).render(context))  # noqa: S704