Skip to content

Pluralized URL slugs for Custom Object Types #179

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1ea9c5c
Merge pull request #159 from netboxlabs/feature
bctiemann Aug 18, 2025
79ad9c6
161 fix multi object field
arthanson Aug 19, 2025
a841e1d
161 fix multi object field
arthanson Aug 19, 2025
b5d2d03
Merge pull request #162 from netboxlabs/161-multi-object-3
bctiemann Aug 19, 2025
0f477a4
Check if unique flag raise validation error if non unique data
arthanson Aug 19, 2025
c111f86
NPL-411 raise validation error if using reserved names for fields
arthanson Aug 19, 2025
962de13
Merge pull request #163 from netboxlabs/npl-389-unique
bctiemann Aug 20, 2025
8abaefa
Merge pull request #164 from netboxlabs/NPL-411-reserved-names
bctiemann Aug 20, 2025
1f0197b
Revert "Check if unique flag raise validation error if non unique data"
bctiemann Aug 20, 2025
1bc72cd
Update docs for 0.2.0
mrmrcoleman Aug 20, 2025
1178246
Merge pull request #167 from netboxlabs/revert-163-npl-389-unique
bctiemann Aug 20, 2025
91335ba
NPL-389 fix unique check
arthanson Aug 20, 2025
636d67d
Merge pull request #168 from netboxlabs/0.2.0-docs
mrmrcoleman Aug 20, 2025
a850a9c
Merge pull request #170 from netboxlabs/npl-389-unique-3
bctiemann Aug 20, 2025
b311f7b
Bump version to 0.2.0 in pyproject.toml
bctiemann Aug 20, 2025
34fb653
174 fix delete of CO on detail page
arthanson Aug 20, 2025
3216a93
174 fix delete of CO on detail page
arthanson Aug 20, 2025
1cf0c80
Merge pull request #175 from netboxlabs/174-delete
bctiemann Aug 20, 2025
f2e2da1
Merge pull request #173 from netboxlabs/bump-pyproject-version
bctiemann Aug 20, 2025
7965692
Add verbose_name and slug fields with help text
bctiemann Aug 21, 2025
80c9110
Change all uses of custom_object_type.name to use slug in urls or ver…
bctiemann Aug 22, 2025
5c0e7e8
Fix tests
bctiemann Aug 22, 2025
cbeff30
Merge branch 'feature' into verbose-plural-name-slug
bctiemann Aug 22, 2025
fdb3dd8
Add new COT fields to serializer
bctiemann Aug 22, 2025
700af72
Merge branch 'feature' into verbose-plural-name-slug
bctiemann Aug 22, 2025
aa38672
Make slug non-nullable and remake migrations
bctiemann Aug 22, 2025
1c8be45
Add CustomObjectType.name validation
bctiemann Aug 22, 2025
f991386
Clean up display_name
bctiemann Aug 22, 2025
b523424
Fix tests
bctiemann Aug 22, 2025
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
9 changes: 6 additions & 3 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,16 @@ class Meta:
"id",
"url",
"name",
"verbose_name",
"verbose_name_plural",
"slug",
"description",
"tags",
"created",
"last_updated",
"fields",
]
brief_fields = ("id", "url", "name", "description")
brief_fields = ("id", "url", "name", "slug", "description")

def create(self, validated_data):
return super().create(validated_data)
Expand Down Expand Up @@ -192,7 +195,7 @@ def get_url(self, obj):
lookup_value = getattr(obj, "pk")
kwargs = {
"pk": lookup_value,
"custom_object_type": obj.custom_object_type.name.lower(),
"custom_object_type": obj.custom_object_type.slug,
}
request = self.context["request"]
format = self.context.get("format")
Expand Down Expand Up @@ -230,7 +233,7 @@ def get_url(self, obj):
lookup_value = getattr(obj, "pk")
kwargs = {
"pk": lookup_value,
"custom_object_type": obj.custom_object_type.name.lower(),
"custom_object_type": obj.custom_object_type.slug,
}
request = self.context["request"]
format = self.context.get("format")
Expand Down
2 changes: 1 addition & 1 deletion netbox_custom_objects/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def get(self, request, *args, **kwargs):
# Extra logic to populate roots for custom object type lists
for custom_object_type in CustomObjectType.objects.all():
local_kwargs = deepcopy(kwargs)
cot_name = custom_object_type.name.lower()
cot_name = custom_object_type.slug
url_name = 'customobject-list'
local_kwargs['custom_object_type'] = cot_name
if namespace:
Expand Down
4 changes: 2 additions & 2 deletions netbox_custom_objects/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class CustomObjectViewSet(ModelViewSet):

def get_view_name(self):
if self.model:
return self.model.custom_object_type.name
return self.model.custom_object_type.verbose_name or self.model.custom_object_type.name
return 'Custom Object'

def get_serializer_class(self):
Expand All @@ -32,7 +32,7 @@ def get_serializer_class(self):
def get_queryset(self):
try:
custom_object_type = CustomObjectType.objects.get(
name__iexact=self.kwargs["custom_object_type"]
slug=self.kwargs["custom_object_type"]
)
except CustomObjectType.DoesNotExist:
raise Http404
Expand Down
4 changes: 2 additions & 2 deletions netbox_custom_objects/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def get_bound_field(self, form, field_name):
widget.attrs["data-url"] = reverse(
viewname,
kwargs={
"custom_object_type": form.instance.custom_object_type.name.lower()
"custom_object_type": form.instance.custom_object_type.slug
},
)

Expand All @@ -70,7 +70,7 @@ def get_bound_field(self, form, field_name):
"url": reverse(
viewname,
kwargs={
"custom_object_type": form.instance.custom_object_type.name.lower()
"custom_object_type": form.instance.custom_object_type.slug
},
),
"params": {},
Expand Down
28 changes: 24 additions & 4 deletions netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from netbox.forms import (NetBoxModelBulkEditForm, NetBoxModelFilterSetForm,
NetBoxModelForm, NetBoxModelImportForm)
from utilities.forms.fields import (CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, TagFilterField)
DynamicModelChoiceField, SlugField, TagFilterField)
from utilities.forms.rendering import FieldSet
from utilities.object_types import object_type_name

Expand All @@ -26,16 +26,36 @@


class CustomObjectTypeForm(NetBoxModelForm):
verbose_name = forms.CharField(
label=_("Readable name"),
max_length=100,
required=False,
help_text=_("Displayed object type name, e.g. \"Vendor Policy\""),
)
verbose_name_plural = forms.CharField(
label=_("Readable plural name"), max_length=100, required=False
label=_("Readable plural name"),
max_length=100,
required=False,
help_text=_("Displayed plural object type name, e.g. \"Vendor Policies\""),
)
slug = SlugField(
slug_source="verbose_name_plural",
help_text=_("URL-friendly unique plural shorthand, e.g. \"vendor-policies\""),
)

fieldsets = (FieldSet("name", "verbose_name_plural", "description", "tags"),)
fieldsets = (
FieldSet(
"name", "verbose_name", "verbose_name_plural", "slug", "description", "tags",
),
)
comments = CommentField()

class Meta:
model = CustomObjectType
fields = ("name", "verbose_name_plural", "description", "comments", "tags")
fields = (
"name", "verbose_name", "verbose_name_plural", "slug", "description",
"comments", "tags",
)


class CustomObjectTypeBulkEditForm(NetBoxModelBulkEditForm):
Expand Down
24 changes: 22 additions & 2 deletions netbox_custom_objects/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 5.2.5 on 2025-08-18 17:15
# Generated by Django 5.2.5 on 2025-08-22 18:57

import django.core.validators
import django.db.models.deletion
Expand Down Expand Up @@ -52,9 +52,29 @@ class Migration(migrations.Migration):
),
("description", models.CharField(blank=True, max_length=200)),
("comments", models.TextField(blank=True)),
("name", models.CharField(max_length=100, unique=True)),
(
"name",
models.CharField(
max_length=100,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Only lowercase alphanumeric characters and underscores are allowed.",
regex="^[a-z0-9_]+$",
),
django.core.validators.RegexValidator(
flags=re.RegexFlag["IGNORECASE"],
inverse_match=True,
message="Double underscores are not permitted in custom object object type names.",
regex="__",
),
],
),
),
("schema", models.JSONField(blank=True, default=dict)),
("verbose_name", models.CharField(blank=True, max_length=100)),
("verbose_name_plural", models.CharField(blank=True, max_length=100)),
("slug", models.SlugField(max_length=100, unique=True)),
(
"tags",
taggit.managers.TaggableManager(
Expand Down
46 changes: 37 additions & 9 deletions netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def __str__(self):
self, primary_field["name"]
)
if not primary_field_value:
return f"{self.custom_object_type.name} {self.id}"
return f"{self.custom_object_type.display_name} {self.id}"
return str(primary_field_value) or str(self.id)

@property
Expand Down Expand Up @@ -134,14 +134,14 @@ def get_absolute_url(self):
"plugins:netbox_custom_objects:customobject",
kwargs={
"pk": self.pk,
"custom_object_type": self.custom_object_type.name.lower(),
"custom_object_type": self.custom_object_type.slug,
},
)

def get_list_url(self):
return reverse(
"plugins:netbox_custom_objects:customobject_list",
kwargs={"custom_object_type": self.custom_object_type.name.lower()},
kwargs={"custom_object_type": self.custom_object_type.slug},
)

@classmethod
Expand All @@ -154,7 +154,7 @@ def _get_viewname(cls, action=None, rest_api=False):
def _get_action_url(cls, action=None, rest_api=False, kwargs=None):
if kwargs is None:
kwargs = {}
kwargs["custom_object_type"] = cls.custom_object_type.name.lower()
kwargs["custom_object_type"] = cls.custom_object_type.slug
return reverse(cls._get_viewname(action, rest_api), kwargs=kwargs)


Expand All @@ -164,9 +164,29 @@ class CustomObjectType(PrimaryModel):
_through_model_cache = (
{}
) # Now stores {custom_object_type_id: {through_model_name: through_model}}
name = models.CharField(max_length=100, unique=True)
name = models.CharField(
max_length=100,
unique=True,
help_text=_("Internal lowercased object name, e.g. \"vendor_policy\""),
validators=(
RegexValidator(
regex=r"^[a-z0-9_]+$",
message=_("Only lowercase alphanumeric characters and underscores are allowed."),
),
RegexValidator(
regex=r"__",
message=_(
"Double underscores are not permitted in custom object object type names."
),
flags=re.IGNORECASE,
inverse_match=True,
),
),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It says lowercased but there is no validation or conversion on this, should we enforce that?

schema = models.JSONField(blank=True, default=dict)
verbose_name = models.CharField(max_length=100, blank=True)
verbose_name_plural = models.CharField(max_length=100, blank=True)
slug = models.SlugField(max_length=100, unique=True, db_index=True)

class Meta:
verbose_name = "Custom Object Type"
Expand All @@ -182,7 +202,7 @@ class Meta:
]

def __str__(self):
return self.name
return self.display_name

@classmethod
def clear_model_cache(cls, custom_object_type_id=None):
Expand Down Expand Up @@ -266,7 +286,7 @@ def get_absolute_url(self):
def get_list_url(self):
return reverse(
"plugins:netbox_custom_objects:customobject_list",
kwargs={"custom_object_type": self.name.lower()},
kwargs={"custom_object_type": self.slug},
)

@classmethod
Expand Down Expand Up @@ -354,16 +374,24 @@ def get_collision_safe_order_id_idx_name(self):
def get_database_table_name(self):
return f"{USER_TABLE_DATABASE_NAME_PREFIX}{self.id}"

@property
def title_case_name(self):
return title(self.verbose_name or self.name)

@property
def title_case_name_plural(self):
return title(self.name) + "s"
return title(self.verbose_name or self.name) + "s"

def get_verbose_name(self):
return self.name
return self.verbose_name or self.title_case_name

def get_verbose_name_plural(self):
return self.verbose_name_plural or self.title_case_name_plural

@property
def display_name(self):
return self.get_verbose_name()

@staticmethod
def get_content_type_label(custom_object_type_id):
custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id)
Expand Down
4 changes: 2 additions & 2 deletions netbox_custom_objects/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def __iter__(self):
add_button.url = reverse_lazy(
f"plugins:{APP_LABEL}:customobject_add",
kwargs={
"custom_object_type": custom_object_type.name.lower()
"custom_object_type": custom_object_type.slug
},
)
menu_item = PluginMenuItem(
Expand All @@ -45,7 +45,7 @@ def __iter__(self):
)
menu_item.url = reverse_lazy(
f"plugins:{APP_LABEL}:customobject_list",
kwargs={"custom_object_type": custom_object_type.name.lower()},
kwargs={"custom_object_type": custom_object_type.slug},
)
yield menu_item

Expand Down
2 changes: 1 addition & 1 deletion netbox_custom_objects/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def render(self, record, table, **kwargs):
get_viewname(model, action),
kwargs={
"pk": record.pk,
"custom_object_type": record.custom_object_type.name.lower(),
"custom_object_type": record.custom_object_type.slug,
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>
{{ object.custom_object_type|linkify:"name" }}
{{ object.custom_object_type|linkify:"display_name" }}
</td>
</tr>
<tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

{% block title %}
{% if object.pk %}
{% trans "Editing" %} {{ object.custom_object_type.get_verbose_name.lower }} {{ object }}
{% trans "Editing" %} {{ object.custom_object_type.display_name.lower }} {{ object }}
{% else %}
{% blocktrans trimmed with custom_object_type=object.custom_object_type.name %}
{% blocktrans trimmed with custom_object_type=object.custom_object_type.display_name %}
Add a new {{ custom_object_type }}
{% endblocktrans %}
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ <h5 class="card-header">{% trans "Custom Object Type" %}</h5>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Readable name" %}</th>
<td>{{ object.verbose_name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
Expand Down
12 changes: 6 additions & 6 deletions netbox_custom_objects/templatetags/custom_object_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def custom_object_clone_button(instance):
viewname = get_viewname(instance, "add")
url = reverse(
viewname,
kwargs={"custom_object_type": instance.custom_object_type.name.lower()}
kwargs={"custom_object_type": instance.custom_object_type.slug}
)

# Populate cloned field values
Expand All @@ -99,7 +99,7 @@ def custom_object_edit_button(instance):
viewname,
kwargs={
"pk": instance.pk,
"custom_object_type": instance.custom_object_type.name.lower(),
"custom_object_type": instance.custom_object_type.slug,
},
)

Expand All @@ -115,7 +115,7 @@ def custom_object_delete_button(instance):
url = reverse(
viewname, kwargs={
"pk": instance.pk,
"custom_object_type": instance.custom_object_type.name.lower(),
"custom_object_type": instance.custom_object_type.slug,
},
)

Expand Down Expand Up @@ -191,7 +191,7 @@ def custom_object_add_button(model, custom_object_type, action="add"):
try:
viewname = get_viewname(model, action)
url = reverse(
viewname, kwargs={"custom_object_type": custom_object_type.name.lower()}
viewname, kwargs={"custom_object_type": custom_object_type.slug}
)
except NoReverseMatch:
url = None
Expand Down Expand Up @@ -250,7 +250,7 @@ def custom_object_bulk_edit_button(
try:
viewname = get_viewname(model, action)
url = reverse(
viewname, kwargs={"custom_object_type": custom_object_type.name.lower()}
viewname, kwargs={"custom_object_type": custom_object_type.slug}
)

if query_params:
Expand All @@ -272,7 +272,7 @@ def custom_object_bulk_delete_button(
try:
viewname = get_viewname(model, action)
url = reverse(
viewname, kwargs={"custom_object_type": custom_object_type.name.lower()}
viewname, kwargs={"custom_object_type": custom_object_type.slug}
)
if query_params:
url = f"{url}?{query_params.urlencode()}"
Expand Down
Loading