Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion docs/field-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The following attributes are available when creating or editing a Custom Object
| `boolean` | True/false |
| `date` | Date |
| `datetime` | Date and time |
| `url` | URL |
| `url` | URL, with an optional link title |
| `json` | Arbitrary JSON value |
| `select` | Single selection from a choice set |
| `multiselect` | Multiple selections from a choice set |
Expand Down Expand Up @@ -107,3 +107,27 @@ Behaviour:
- **Map link.** Detail views render the coordinates with a **Map** button that opens the location
using NetBox's `MAPS_URL` configuration parameter (Google Maps by default).
- `Must be unique` and `Default` are not supported for `coordinates` fields.

## URL Fields

Field type: `url`

A `url` field stores the URL value plus an optional human-readable **link title**.
Adding one `url` field named `website` creates two backing columns:

- `website` — the URL itself
- `website_title` — optional display text shown in place of the raw URL on the
object's detail page

Behaviour:

- **Detail page only.** The title is used as the visible link text on the object
detail page. List/table views continue to show the raw URL as plain text.
- **Optional independently.** The title has no relationship to the URL value —
setting one without the other is allowed and harmless.
- **REST API.** The pair is exposed as two flat fields, `<name>` and `<name>_title`.
- `Must be unique` and `Default` continue to apply to the URL value itself, exactly
as for any other `url` field.
- **CSV import.** Bulk CSV import only populates the URL value; the title column is
not importable via CSV (the same limitation applies to `coordinates` fields' backing
columns).
5 changes: 5 additions & 0 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,11 @@ def get_serializer_class(model, skip_object_fields=False):
if field.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES:
custom_field_names += [f"{field.name}_latitude", f"{field.name}_longitude"]
continue
# URL fields expand into the URL itself plus an optional title column;
# expose both flat, mirroring the coordinates treatment above.
if field.type == CustomObjectFieldTypeChoices.TYPE_URL:
custom_field_names += [field.name, f"{field.name}_title"]
continue
if field.name not in model_field_names:
continue # excluded during model generation (e.g. broken FK)
if skip_object_fields and field.type in [
Expand Down
54 changes: 53 additions & 1 deletion netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,18 +524,70 @@ def get_filterform_field(self, field, **kwargs):


class URLFieldType(FieldType):
"""
A URL field. Expands into two real DB columns: ``<name>``, the URL itself, and
``<name>_title``, an optional human-readable title shown in place of the raw URL
on an object's detail page. Unlike CoordinatesFieldType, the primary ``<name>``
column keeps behaving like any other single-value column (unique/default/regex
validation all still apply to it) -- only the title is new.
"""

graphql_annotation = str

@staticmethod
def title_field_name(field):
return f"{field.name}_title"

def get_model_field(self, field, **kwargs):
field_kwargs = self._safe_kwargs(**kwargs)
field_kwargs.update({"default": field.default, "unique": field.unique})
return models.URLField(null=True, blank=True, **field_kwargs)
return {
field.name: models.URLField(null=True, blank=True, **field_kwargs),
# blank=True, default='' (not null=True): a CharField with null=True lets
# "no title" be represented as both NULL (ORM/API create omitting the key)
# and '' (a form submission clearing the field), which would make
# isnull-based filtering unreliable. default='' also keeps this column
# eligible for mixin_migration.py's post_migrate auto-heal pass on
# existing installations, which only auto-ADDs a new column when it is
# nullable or has a Django-level default (see _can_auto_add()).
self.title_field_name(field): models.CharField(
max_length=200,
blank=True,
default="",
help_text=_("Human-readable text shown instead of the raw URL."),
),
}

def get_form_field(self, field, **kwargs):
return LaxURLField(
assume_scheme="https", required=field.required, initial=field.default
)

def get_form_fields(self, field):
"""
Return the URL and title form fields, keyed by their backing column names
(mirrors CoordinatesFieldType.get_form_fields).
"""
base_label = field.label or field.name.replace("_", " ").title()
url_field = LaxURLField(
label=base_label,
assume_scheme="https",
required=field.required,
initial=field.default,
)
title_field = forms.CharField(
label=f"{base_label} ({_('link title')})",
required=False,
max_length=200,
)
if field.ui_editable != CustomFieldUIEditableChoices.YES:
url_field.disabled = True
title_field.disabled = True
return {
field.name: url_field,
self.title_field_name(field): title_field,
}

def get_filterform_field(self, field, **kwargs):
return forms.CharField(
label=field,
Expand Down
8 changes: 8 additions & 0 deletions netbox_custom_objects/mixin_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ def _expected_base_fields(cot, model=None):
(f.name). This is equivalent to matching by f.column for user-defined COT
fields because they are never created with db_column overrides.

Multi-column custom field types (e.g. CoordinatesFieldType's "_latitude"/
"_longitude", URLFieldType's "_title") are NOT excluded here, since their
backing columns' attribute names never equal the user field's own f.name --
so this heal pass also covers them as a deliberate side effect. This is why
a newly introduced sub-column for one of those types needs no dedicated
migration/upgrade code: as long as it's nullable, the next post_migrate run
(or `upgrade_custom_objects`) auto-adds it to every existing COT table.

Pass *model* to avoid a second get_model() call when the caller already
holds the model reference.
"""
Expand Down
129 changes: 96 additions & 33 deletions netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,32 @@ def _apply_deferred_co_field(field_instance):
_deferred_co_field_data.set(None)


# Field types whose FieldType.get_model_field() returns a dict of multiple backing
# columns rather than a single Field (coordinates: latitude/longitude; url: the URL
# itself plus an optional title). Used to reject type conversion to/from these types
# (see CustomObjectTypeField.clean()) and to widen the backing-column-collision scan.
MULTI_COLUMN_TYPES = {
CustomObjectFieldTypeChoices.TYPE_COORDINATES,
CustomObjectFieldTypeChoices.TYPE_URL,
}


def _primary_model_field(fi):
"""fi's own <name>-column Field, unwrapping FieldType.get_model_field()'s dict
return for a multi-column type (coordinates, url) so single-column DDL/validation
code can keep treating every field type uniformly."""
mf = FIELD_TYPE_CLASS[fi.type]().get_model_field(fi)
return mf[fi.name] if isinstance(mf, dict) else mf


def _schema_add_field(fi, model, schema_editor, schema_conn):
"""``add_field`` against *schema_conn*; idempotent (skips if column exists).

Creates the through table for MULTIOBJECT. Deferred CO field data is NOT
applied here — call ``_apply_deferred_co_field`` separately after.
"""
ft = FIELD_TYPE_CLASS[fi.type]()
mf = ft.get_model_field(fi)
mf = _primary_model_field(fi)
mf.contribute_to_class(model, fi.name)

with schema_conn.cursor() as cursor:
Expand Down Expand Up @@ -342,8 +360,7 @@ def _schema_remove_field(fi, model, schema_editor, schema_conn=None, existing_ta
PostgreSQL doesn't reject the call with "pending trigger events".
*existing_tables* optionally short-circuits the per-call introspection.
"""
ft = FIELD_TYPE_CLASS[fi.type]()
mf = ft.get_model_field(fi)
mf = _primary_model_field(fi)
mf.contribute_to_class(model, fi.name)

if fi.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
Expand Down Expand Up @@ -397,8 +414,8 @@ def _schema_alter_field(old_fi, new_fi, model, schema_editor, schema_conn, exist
)
return

old_mf = FIELD_TYPE_CLASS[old_fi.type]().get_model_field(old_fi)
new_mf = FIELD_TYPE_CLASS[new_fi.type]().get_model_field(new_fi)
old_mf = _primary_model_field(old_fi)
new_mf = _primary_model_field(new_fi)
old_mf.contribute_to_class(model, old_fi.name)
new_mf.contribute_to_class(model, new_fi.name)

Expand Down Expand Up @@ -437,7 +454,7 @@ def _schema_alter_field(old_fi, new_fi, model, schema_editor, schema_conn, exist
new_fi.pk, schema_conn.alias,
)
return
live_mf = FIELD_TYPE_CLASS[live_fi.type]().get_model_field(live_fi)
live_mf = _primary_model_field(live_fi)
live_mf.contribute_to_class(model, live_fi.name)
if live_mf.column not in existing_cols:
logger.debug(
Expand Down Expand Up @@ -955,6 +972,8 @@ def clone_fields(self):
# Coordinates fields have no single column; clone the two backing columns.
if field_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES:
names += [f"{name}_latitude", f"{name}_longitude"]
elif field_type == CustomObjectFieldTypeChoices.TYPE_URL:
names += [name, f"{name}_title"]
else:
names.append(name)

Expand Down Expand Up @@ -2667,12 +2686,11 @@ def clean(self):
and hasattr(self, '_original')
and not self.original.unique
):
field_type = FIELD_TYPE_CLASS[self.type]()
model_field = field_type.get_model_field(self)
model_field = _primary_model_field(self)
model = self.custom_object_type.get_model()
model_field.contribute_to_class(model, self.name)

old_field = field_type.get_model_field(self.original)
old_field = _primary_model_field(self.original)
old_field.contribute_to_class(model, self._original_name)

# Route the probe through the branch's connection so the ALTER
Expand Down Expand Up @@ -2811,46 +2829,53 @@ def clean(self):
{"name": _("Cannot rename a polymorphic field after creation.")}
)

# Prevent converting an existing field to or from coordinates.
# Prevent converting an existing field to or from a multi-column type
# (coordinates, url).
#
# A coordinates field occupies two concrete columns ("{name}_latitude" and
# "{name}_longitude") while every other field type occupies a single column
# named "{name}". The save() path has no logic to migrate between those two
# shapes, so allowing the conversion would either leave the original column
# orphaned (→ coordinates) or attempt to alter a column that doesn't exist
# (coordinates → other), raising an uncaught database error. Reject it here,
# A multi-column field occupies more than one concrete column ("{name}_latitude"/
# "{name}_longitude" for coordinates, "{name}"/"{name}_title" for url) while every
# other field type (and, prior to this conversion, the field itself) occupies a
# single column. The save() path has no logic to migrate between those shapes, so
# allowing the conversion would either leave columns orphaned or attempt to alter a
# column that doesn't exist, raising an uncaught database error. Reject it here,
# mirroring the polymorphic-flag guard above.
is_coordinates = self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES
was_coordinates = self._original_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES
if self.pk and not self._state.adding and is_coordinates != was_coordinates:
if (
self.pk
and not self._state.adding
and self.type != self._original_type
and (self.type in MULTI_COLUMN_TYPES or self._original_type in MULTI_COLUMN_TYPES)
):
raise ValidationError(
{"type": _("Cannot change a field's type to or from coordinates after creation.")}
{"type": _(
"Cannot change a field's type to or from a multi-column type "
"(coordinates, URL) after creation."
)}
)

# Guard against backing-column name collisions.
#
# A coordinates field expands into "{name}_latitude"/"{name}_longitude". If a
# sibling field already occupies one of those column names (or vice versa: a
# plain field named "<coord>_latitude"), the schema editor would issue a
# duplicate-column ALTER and PostgreSQL would raise a ProgrammingError instead
# of a clean validation error. Detect the overlap here.
# A multi-column field expands into more than one real column (see above). If a
# sibling field already occupies one of those column names (or vice versa: a plain
# field named e.g. "<coord>_latitude" or "<url>_title"), the schema editor would
# issue a duplicate-column ALTER and PostgreSQL would raise a ProgrammingError
# instead of a clean validation error. Detect the overlap here.
if self.custom_object_type_id:
def _occupied_columns(name, field_type):
if field_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES:
return {f"{name}_latitude", f"{name}_longitude"}
if field_type == CustomObjectFieldTypeChoices.TYPE_URL:
return {name, f"{name}_title"}
return {name}

own_columns = _occupied_columns(self.name, self.type)
# A clash can only involve a coordinates field's expanded columns. If
# this field isn't coordinates, only sibling coordinates fields can
# collide with it — so skip the full sibling scan and query just those
# (usually zero) to avoid an extra queryset on every field's save.
if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES:
# A clash can only involve a multi-column field's expanded columns. If this
# field isn't multi-column, only sibling multi-column fields can collide with
# it — so skip the full sibling scan and query just those (usually zero) to
# avoid an extra queryset on every field's save.
if self.type in MULTI_COLUMN_TYPES:
siblings = self.custom_object_type.fields.all()
else:
siblings = self.custom_object_type.fields.filter(
type=CustomObjectFieldTypeChoices.TYPE_COORDINATES
)
siblings = self.custom_object_type.fields.filter(type__in=MULTI_COLUMN_TYPES)
if self.pk:
siblings = siblings.exclude(pk=self.pk)
for sibling in siblings:
Expand Down Expand Up @@ -3516,6 +3541,25 @@ def save(self, *args, **kwargs):
else:
_schema_add_field(self, model, schema_editor, schema_conn)
_apply_deferred_co_field(self)
if self.type == CustomObjectFieldTypeChoices.TYPE_URL:
# A url field's title column has no deferred-CO-field
# replay support (matches the existing coordinates gap).
title_name = field_type.title_field_name(self)
title_field = field_type.get_model_field(self)[title_name]
title_field.contribute_to_class(model, title_name)
with schema_conn.cursor() as cursor:
existing_cols = {
col.name for col in schema_conn.introspection.get_table_description(
cursor, model._meta.db_table
)
}
if title_field.column in existing_cols:
logger.debug(
'_schema_add_field: %r already exists on %s, skipping',
title_field.column, model._meta.db_table,
)
else:
schema_editor.add_field(model, title_field)
else:
if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES:
# Only a rename touches the schema; other attribute changes
Expand All @@ -3536,6 +3580,19 @@ def save(self, *args, **kwargs):
)
else:
_schema_alter_field(self.original, self, model, schema_editor, schema_conn)
if (
self.type == CustomObjectFieldTypeChoices.TYPE_URL
and self.name != self._original_name
):
# Only a rename touches the title column; other attribute
# changes are persisted by super().save() below.
old_title = field_type.title_field_name(self.original)
new_title = field_type.title_field_name(self)
old_title_field = field_type.get_model_field(self.original)[old_title]
new_title_field = field_type.get_model_field(self)[new_title]
old_title_field.contribute_to_class(model, old_title)
new_title_field.contribute_to_class(model, new_title)
schema_editor.alter_field(model, old_title_field, new_title_field)

# Rewrite historical audit-data keys so any future replay can
# resolve old or new name to the current field name.
Expand Down Expand Up @@ -3660,6 +3717,12 @@ def delete(self, *args, **kwargs):
except LookupError:
pass
_schema_remove_field(self, model, schema_editor, schema_conn=schema_conn)
if self.type == CustomObjectFieldTypeChoices.TYPE_URL:
# Drop the title column alongside the primary URL column.
title_name = field_type.title_field_name(self)
title_field = field_type.get_model_field(self)[title_name]
title_field.contribute_to_class(model, title_name)
schema_editor.remove_field(model, title_field)

# Deregister the dropped through model (both polymorphic and plain MULTIOBJECT)
# so the cascade-delete collector no longer queries the now-missing table.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@
{{ ''|placeholder }}
{% endif %}
{% endwith %}
{% elif field.type == 'url' %}
{% with url_html=object|get_url_field_html:field %}
{% if url_html %}
{{ url_html }}
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
{% else %}
{% customfield_value field object|get_field_value:field %}
{% endif %}
Expand Down
Loading