Skip to content

Closes #496: Add optional link title to URL fields - #641

Open
bctiemann wants to merge 4 commits into
featurefrom
496-url-field-link-title
Open

Closes #496: Add optional link title to URL fields#641
bctiemann wants to merge 4 commits into
featurefrom
496-url-field-link-title

Conversation

@bctiemann

Copy link
Copy Markdown
Contributor

Summary

  • A url-type CustomObjectTypeField now expands into two real DB columns: the URL itself, and an optional _title used as the visible link text on an object's detail page instead of the raw URL (falling back to the URL itself when no title is set). List/table views are unaffected -- still plain-text URL.
  • Mirrors CoordinatesFieldType's existing two-column pattern, but unlike coordinates the primary URL column keeps behaving like any other single-value column: unique, default, and regex validation all still apply to it exactly as before.
  • This required making three previously coordinates-only DDL/validation code paths in models.py dict-aware (the generic single-column schema helpers used by every field type, the unique-conversion probe in clean(), and the backing-column-collision guard), since URL is the first multi-column type that also needs to flow through the generic single-column path via its unique/default support.
  • No new migration or upgrade script is needed for existing installations: the plugin's existing post_migrate schema-heal pass already covers any nullable non-mixin column whose attribute name doesn't match a user field's own name, which the new title column satisfies the same way coordinates' latitude/longitude columns already do. Documented this explicitly in mixin_migration.py.

Closes: #496

Test plan

  • New/extended tests across test_field_types.py (URLFieldTypeTestCase: model generation, title-optional, unique still enforced, type-conversion rejection both directions, backing-column collision both directions), test_schema_operations.py (rename and delete both drop/rename the title column), test_api.py (serializer exposes both columns flat, create round-trip with and without a title), test_views.py (add form renders both inputs, create with and without a title).
  • ruff check clean across the whole package.
  • Ran the full plugin test suite (1109 tests) against a NetBox 4.6.6 checkout. All new/changed tests pass. The only pre-existing failures (20, unrelated to this change) trace to an environment gap in that test setup -- netbox_branching is importable but not enabled in PLUGINS, which breaks any test touching CustomObjectTypeField/CustomObjectType rename or delete, confirmed by the identical failure occurring on the unmodified, pre-existing coordinates rename test -- plus one unrelated csv_update_data scaffolding gap in generic view tests.

A url-type CustomObjectTypeField now expands into two real DB columns:
the URL itself, and an optional <name>_title used as the visible link
text on an object's detail page instead of the raw URL (falling back
to the URL when no title is set). Mirrors CoordinatesFieldType's
existing two-column pattern, but unlike coordinates the primary URL
column keeps behaving like any other single-value column -- unique,
default, and regex validation all still apply to it.

This required making three previously coordinates-only DDL/validation
code paths in models.py dict-aware (the generic single-column schema
helpers, the unique-conversion probe in clean(), and the backing-
column-collision guard), since URL is the first multi-column type to
also flow through the generic single-column path via its unique/
default support.

No new migration or upgrade script is needed for existing
installations: the plugin's existing post_migrate schema-heal pass
already covers any nullable non-mixin column whose attribute name
doesn't match a user field's own name, which the new title column
satisfies the same way coordinates' latitude/longitude columns already
do.
url_scheme_is_allowed() only exists on NetBox's feature branch (added
2026-07-23), not in main or any released version, so importing it
broke CI's NetBox-main test matrix leg with an ImportError at plugin
load time. This plugin supports NetBox 4.5.2+, well before that
function existed.

Reimplement the same scheme-allowlist check locally in
custom_object_utils.py instead. ALLOWED_URL_SCHEMES itself (the
config parameter the function reads) has existed since 2020 and is
safe to rely on across the whole supported version range.
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review and limit response only to areas of potential concern.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 6m 45s —— View job


Review: Areas of Potential Concern

Limiting to concerns only, as requested.


1. No tests for get_url_field_html / _url_scheme_is_allowed — including the security-relevant scheme guard

_url_scheme_is_allowed is the only line of defense preventing a javascript: URL from rendering as a clickable link. There are no tests for this function or the get_url_field_html filter directly. The existing view tests only check HTTP 302 redirects after form submission; they don't render the detail page and verify the output HTML.

In particular, the following cases are untested:

  • A URL with a disallowed scheme (e.g., javascript:alert(1)) renders as plain text, not a link
  • A URL with a title renders <a href="...">Title</a>
  • A URL with no title falls back to the URL text
  • An object with no URL at all renders '' (the placeholder path)

This is a gap worth filling given that _url_scheme_is_allowed is a reimplemented local copy of a NetBox core function, and any divergence from NetBox's version (e.g., new scheme normalisation logic upstream) would be silent.

Fix this →%20URL%20with%20allowed%20scheme%20renders%20as%20%3Ca%20href%3D%22...%22%3E%3C%2Fa%3E%3B%202)%20javascript%3A%20URL%20renders%20as%20plain%20text%2C%20no%20link%3B%203)%20title%20is%20used%20as%20link%20text%20when%20set%3B%204)%20URL%20text%20used%20as%20fallback%20when%20title%20is%20absent.%20Also%20add%20unit%20tests%20for%20_url_scheme_is_allowed%20in%20test_field_types.py.&repo=netboxlabs/netbox-custom-objects)


2. CSV bulk import silently drops the _title column

CustomObjectBulkImportView (views.py:1452) builds its form by iterating fields and calling get_annotated_form_field(field, for_csv_import=True). URLFieldType doesn't override get_annotated_form_field, so it calls the single-field get_form_field, which returns only a LaxURLField for the URL column. The _title column is never added to the import form.

A user exporting a COT with titled URLs and re-importing the CSV will silently lose all link titles. There's no error and no documentation warning. This could be acceptable as an MVP limitation, but it should at minimum be documented in docs/field-attributes.md.


3. Title column add_field call is not idempotent (unlike the URL column)

In CustomObjectTypeField.save() (models.py:3544), when adding a new URL field:

_schema_add_field(self, model, schema_editor, schema_conn)   # idempotent — checks column exists first
_apply_deferred_co_field(self)
if self.type == CustomObjectFieldTypeChoices.TYPE_URL:
    ...
    schema_editor.add_field(model, title_field)               # NOT idempotent

_schema_add_field explicitly checks whether the column already exists before issuing ALTER TABLE. The bare schema_editor.add_field(model, title_field) call does not — it would raise DuplicateColumn if retried in the rare case where the URL column was added but the transaction for the title column was not yet committed when the process was interrupted. In practice both operations commit together, so the risk is low. However, it's worth applying the same idempotency guard (checking existing_cols before calling add_field) to match the established pattern in _schema_add_field.


4. CharField(null=True, blank=True) creates dual empty states

The title model field (field_types.py):

models.CharField(max_length=200, null=True, blank=True, ...)

Django's CharField with null=True means "no title" can be represented as either NULL (ORM create without passing title) or "" (form submission with empty field). The display logic handles both via title or url, but comparisons like website_title="" vs website_title__isnull=True will behave differently, and assertIsNone(obj.website_title) in the API test would silently pass for NULL but fail for "".

The conventional Django solution is to store NULL consistently for "not set" by overriding the model's clean() to coerce ""None. Or drop null=True and treat empty string as "no title" consistently. Neither is urgent, but the dual-state can cause subtle filtering bugs.


5. Minor: MULTI_COLUMN_TYPES extends type-conversion guard but URL → URL rename is not re-tested

The new guard in clean():

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)
):

The condition requires self.type != self._original_type, so a URL field being renamed (same type, different name) correctly skips this branch. But the test test_change_existing_field_to_url_rejected starts from a text field, not from another multi-column type. A conversion between two different multi-column types (e.g., hypothetically coordinates → url) would also hit this guard, but that path isn't tested. Not a current bug (only two multi-column types exist and they share the same guard), but worth a comment noting the intent for future field types.


- Add tests for get_url_field_html and its security-relevant scheme
  guard (_url_scheme_is_allowed): allowed scheme with title, allowed
  scheme falling back to the URL text, a disallowed scheme (e.g.
  javascript:) rendering as plain text instead of a link, and an
  unset URL returning ''.
- Document that CSV bulk import only populates the URL value, not the
  title (matches the existing, pre-established limitation for
  coordinates fields' backing columns).
- Make the title column's schema_editor.add_field() call idempotent,
  checking existing_cols first, matching the established pattern in
  _schema_add_field() (the URL column already had this guard; the
  title column's separate add_field() call did not).
- Change the title column from CharField(null=True, blank=True) to
  CharField(blank=True, default=""), so "no title" has one canonical
  representation instead of two (NULL vs ''). default="" keeps the
  column eligible for mixin_migration.py's auto-heal pass on existing
  installations, which requires a column to be nullable or have a
  Django-level default before auto-adding it.
@bctiemann
bctiemann requested review from a team and pheus and removed request for a team August 3, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant