Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 9 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,20 @@ See [Field Attributes](field-attributes.md) for the full list of attributes avai

A field's `Must be unique` setting makes its values unique on their own. To require that a *combination* of field values is unique, for example a serial number that only has to be unique per vendor, add a constraint: open the Custom Object Type, go to the `Constraints` tab and click `+ Add Constraint`.

- **Fields** — the fields whose combined values must be unique. Pick at least two, or one when the constraint is case-insensitive. Text, long text, integer, decimal, boolean, date, datetime, URL, selection and object fields (including polymorphic object fields, which count as both the object's type and its ID) can be included.
- **Fields** — the fields whose combined values must be unique. Pick at least two, or one when the constraint is case-insensitive or has a condition. Text, long text, integer, decimal, boolean, date, datetime, URL, selection and object fields (including polymorphic object fields, which count as both the object's type and its ID) can be included.
- **Case-insensitive** — compare text and URL fields without regard to case, so `ACME` and `acme` conflict.
- **Empty values are distinct** — enabled by default, so objects that leave any of the constrained fields empty never conflict. Disable it to treat empty values as equal.
- **Condition** — optionally, apply the constraint only to objects that match all of a list of predicates, for example to make `name` unique only among objects with no `parent`:

```json
[{"field": "parent", "lookup": "isnull", "value": true}]
```

Each predicate names a field and a lookup: `exact`, `n` (does not equal, or is empty), `in` (a list of values) or `isnull` (`true` for empty, `false` for not empty). The same field types as above can be tested; object fields can only be tested with `isnull`.

The database enforces constraints, so they apply to the UI, the REST API, imports and scripts alike, and a constraint can't be added while existing objects already break it. Saving an object that would break one shows a validation error.

A constrained field can be renamed. Its type can't be changed until it's removed from its constraints, and deleting it deletes the constraints that include it.
A constrained field can be renamed. Its type can't be changed until it's removed from its constraints (including their conditions), and deleting it deletes the constraints that include or test it.

### Interacting with Custom Objects

Expand Down
5 changes: 5 additions & 0 deletions docs/portable-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ constraints:
- name: serial_ci
fields: [2]
case_insensitive: true
- name: name_unique_top_level
fields: [3]
condition:
- {field: 4, lookup: isnull, value: true}
```

| Key | Required | Description |
Expand All @@ -234,6 +238,7 @@ constraints:
| `type` | no | `"unique"`. Default: `"unique"`. |
| `case_insensitive` | no | Compare text and URL fields without regard to case. Default: `false`. |
| `nulls_distinct` | no | Objects that leave a constrained field empty never conflict. Default: `true`. |
| `condition` | no | Apply the constraint only to objects matching all of these predicates. Each is `{field, lookup, value}`: `field` is a field `id`; `lookup` is `exact`, `n` (does not equal, or is empty), `in` (`value` is a list) or `isnull` (`value` is `true` or `false`). Object fields can only be tested with `isnull`. Default: `[]`. |
| `description` | no | Short description (max 200 characters). |

Constraints are matched by name. As with fields, a constraint in the DB but absent from
Expand Down
3 changes: 2 additions & 1 deletion docs/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,10 @@ Require a combination of field values to be unique by creating a constraint with
| `custom_object_type` | yes | ID of the Custom Object Type. |
| `name` | yes | Internal name. Lowercase alphanumeric with underscores only; unique within the Custom Object Type. |
| `type` | no | `unique` (the only type at present). |
| `fields` | yes | IDs of the fields whose combined values must be unique: at least two, or one when `case_insensitive` is `true`. Responses list each field's `id`, `url`, `name` and `schema_id`. |
| `fields` | yes | IDs of the fields whose combined values must be unique: at least two, or one when `case_insensitive` is `true` or there's a `condition`. Responses list each field's `id`, `url`, `name` and `schema_id`. |
| `case_insensitive` | no | Compare `text`, `longtext` and `url` fields without regard to case. Default: `false`. |
| `nulls_distinct` | no | When `true`, objects that leave any constrained field empty never conflict. Default: `true`. |
| `condition` | no | Apply the constraint only to objects matching all of these predicates, e.g. `[{"field": 43, "lookup": "isnull", "value": true}]`. `field` is a field ID (responses show the brief field object); `lookup` is `exact`, `n` (does not equal, or is empty), `in` (`value` is a list) or `isnull` (`value` is `true` or `false`). Object fields can only be tested with `isnull`. Default: `[]`. |
| `description` | no | A short description. |

Creating or changing a constraint returns `400 Bad Request` if existing objects already violate it. Writes to custom objects that would violate a constraint also return `400 Bad Request`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The kind of constraint. Only `Unique` is currently supported.

### Fields

The fields whose combined values must be unique. Choose at least two, or one when the constraint is case-insensitive. Multi-object, multi-select, JSON and coordinates fields can't be included.
The fields whose combined values must be unique. Choose at least two, or one when the constraint is case-insensitive or has a condition. Multi-object, multi-select, JSON and coordinates fields can't be included.

### Case-Insensitive

Expand All @@ -24,6 +24,10 @@ Compare text, long text and URL fields without regard to case.

When enabled (the default), objects that leave any of the constrained fields empty never conflict. Disable it to treat empty values as equal.

### Condition

Optionally, apply the constraint only to objects that match all of a list of predicates, e.g. `[{"field": "parent", "lookup": "isnull", "value": true}]`. Each predicate names a field and a lookup: `exact`, `n` (does not equal, or is empty), `in` (a list of values) or `isnull` (`true` for empty, `false` for not empty). Object fields can only be tested with `isnull`.

### Description

A short, optional description of the constraint.
110 changes: 86 additions & 24 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from users.api.serializers_.owners import OwnerSerializer

from netbox_custom_objects import constants, field_types
from netbox_custom_objects.choices import CustomObjectFieldTypeChoices
from netbox_custom_objects.choices import ConstraintLookupChoices, CustomObjectFieldTypeChoices
from netbox_custom_objects.models import (ConfigContextRenderer, CustomObject, CustomObjectType,
CustomObjectTypeConstraint, CustomObjectTypeField)

Expand Down Expand Up @@ -346,17 +346,33 @@ def create(self, validated_data):
return super().create(validated_data)


def _brief_constraint_field(field, request):
return {
"id": field.pk,
"url": reverse(
"plugins-api:netbox_custom_objects-api:customobjecttypefield-detail",
kwargs={"pk": field.pk},
request=request,
),
"name": field.name,
"schema_id": field.schema_id,
}


_BRIEF_CONSTRAINT_FIELD_SCHEMA = {
"type": "object",
"properties": {
"id": {"type": "integer"},
"url": {"type": "string", "format": "uri"},
"name": {"type": "string"},
"schema_id": {"type": "integer"},
},
}


@extend_schema_field({
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"url": {"type": "string", "format": "uri"},
"name": {"type": "string"},
"schema_id": {"type": "integer"},
},
},
"items": _BRIEF_CONSTRAINT_FIELD_SCHEMA,
"description": "Field IDs on write; brief field objects on read.",
})
class ConstraintFieldsField(serializers.Field):
Expand All @@ -369,19 +385,7 @@ def __init__(self, **kwargs):

def to_representation(self, constraint):
request = self.context.get("request")
return [
{
"id": field.pk,
"url": reverse(
"plugins-api:netbox_custom_objects-api:customobjecttypefield-detail",
kwargs={"pk": field.pk},
request=request,
),
"name": field.name,
"schema_id": field.schema_id,
}
for field in constraint.get_fields()
]
return [_brief_constraint_field(field, request) for field in constraint.get_fields()]

def to_internal_value(self, data):
if not isinstance(data, list) or not data:
Expand All @@ -397,11 +401,63 @@ def to_internal_value(self, data):
return {"_constraint_fields": [by_pk[pk] for pk in pks]}


@extend_schema_field({
"type": "array",
"items": {
"type": "object",
"properties": {
"field": _BRIEF_CONSTRAINT_FIELD_SCHEMA,
"lookup": {"type": "string", "enum": ConstraintLookupChoices.values()},
"value": {},
},
},
"description": "Predicates that must all hold for the constraint to apply. Field IDs on write; "
"brief field objects on read.",
})
class ConstraintConditionField(serializers.Field):
"""
A constraint's condition: predicates that name a field by ID on write and as a brief field object on read.
"""

def __init__(self, **kwargs):
super().__init__(source="*", **kwargs)

def to_representation(self, constraint):
request = self.context.get("request")
by_schema_id = {f.schema_id: f for f in constraint.custom_object_type.fields.all()}
result = []
for predicate in constraint.condition or ():
field = by_schema_id.get(predicate.get("field"))
result.append({
**predicate,
"field": _brief_constraint_field(field, request) if field else None,
})
return result

def to_internal_value(self, data):
if data is None:
return {"_condition": []}
if not isinstance(data, list):
raise ValidationError(_("Expected a list of predicates."))
if not all(isinstance(p, dict) and "field" in p for p in data):
raise ValidationError(_("Each predicate needs a field ID."))
try:
pks = [int(p["field"]) for p in data]
except (TypeError, ValueError):
raise ValidationError(_("Each predicate needs a field ID."))
by_pk = CustomObjectTypeField.objects.in_bulk(pks)
missing = [pk for pk in pks if pk not in by_pk]
if missing:
raise ValidationError(_("Unknown field ID(s): {ids}").format(ids=", ".join(map(str, missing))))
return {"_condition": [{**p, "field": by_pk[pk]} for p, pk in zip(data, pks)]}


class CustomObjectTypeConstraintSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_custom_objects-api:customobjecttypeconstraint-detail"
)
fields = ConstraintFieldsField()
condition = ConstraintConditionField(required=False)

class Meta:
model = CustomObjectTypeConstraint
Expand All @@ -415,6 +471,7 @@ class Meta:
"fields",
"case_insensitive",
"nulls_distinct",
"condition",
"description",
"created",
"last_updated",
Expand All @@ -426,14 +483,19 @@ def validate(self, data):
new_cot = data.get("custom_object_type")
if self.instance is not None and new_cot is not None and new_cot != self.instance.custom_object_type:
raise ValidationError({"custom_object_type": _("A constraint can't be moved to another type.")})
cot = data.get("custom_object_type") or getattr(self.instance, "custom_object_type", None)
members = data.pop("_constraint_fields", None)
if members is not None:
cot = data.get("custom_object_type") or getattr(self.instance, "custom_object_type", None)
if any(f.custom_object_type_id != getattr(cot, "pk", None) for f in members):
raise ValidationError({"fields": _("Every field must belong to this custom object type.")})
data["field_schema_ids"] = [f.schema_id for f in members]
elif self.instance is None:
raise ValidationError({"fields": _("This field is required.")})
condition = data.pop("_condition", None)
if condition is not None:
if any(p["field"].custom_object_type_id != getattr(cot, "pk", None) for p in condition):
raise ValidationError({"condition": _("Every field must belong to this custom object type.")})
data["condition"] = [{**p, "field": p["field"].schema_id} for p in condition]
return super().validate(data)


Expand Down
6 changes: 4 additions & 2 deletions netbox_custom_objects/branching.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,19 @@ def _collect_co_refs(model_class, data, model_label=None):


def _constraint_field_refs(data, fields_by_schema_id):
"""Return the field refs a constraint's *data* covers, for fields changed in the same squash.
"""Return the field refs a constraint's *data* covers or tests, for fields changed in the same squash.

Constraints reference fields by ``schema_id`` (an integer list that the generic
walker would misread as self-references), so map them to field keys explicitly.
"""
if not data:
return set()
cot_id = data.get('custom_object_type')
schema_ids = list(data.get('field_schema_ids') or ())
schema_ids += [p.get('field') for p in data.get('condition') or () if isinstance(p, dict)]
return {
fields_by_schema_id[(cot_id, sid)]
for sid in data.get('field_schema_ids') or ()
for sid in schema_ids
if (cot_id, sid) in fields_by_schema_id
}

Expand Down
15 changes: 15 additions & 0 deletions netbox_custom_objects/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,18 @@ class ConstraintTypeChoices(ChoiceSet):
CHOICES = (
(UNIQUE, _("Unique")),
)


class ConstraintLookupChoices(ChoiceSet):
"""How a constraint condition compares a field's value."""
EXACT = "exact"
NOT_EQUAL = "n"
IN = "in"
ISNULL = "isnull"

CHOICES = (
(EXACT, _("Equals")),
(NOT_EQUAL, _("Does not equal (or is empty)")),
(IN, _("Is one of")),
(ISNULL, _("Is empty")),
)
36 changes: 35 additions & 1 deletion netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from netbox.forms.mixins import ChangelogMessageMixin
from utilities.forms.fields import (CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField,
DynamicModelChoiceField, SlugField, TagFilterField)
DynamicModelChoiceField, JSONField, SlugField, TagFilterField)
from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_field_value
from utilities.object_types import object_type_name
Expand Down Expand Up @@ -449,6 +449,15 @@ class CustomObjectTypeConstraintForm(ChangelogMessageMixin, forms.ModelForm):
label=_("Fields"),
help_text=_("The fields whose combined values must be unique"),
)
condition = JSONField(
required=False,
label=_("Condition"),
help_text=_(
"Apply the constraint only to objects matching all of these predicates. Lookups: exact, n (not equal), "
'in (a list of values), isnull (true or false). Example: '
'<code>[{"field": "parent", "lookup": "isnull", "value": true}]</code>'
Comment thread
bctiemann marked this conversation as resolved.
Outdated
),
)

fieldsets = (
FieldSet(
Expand All @@ -458,6 +467,7 @@ class CustomObjectTypeConstraintForm(ChangelogMessageMixin, forms.ModelForm):
"field_schema_ids",
"case_insensitive",
"nulls_distinct",
"condition",
"description",
name=_("Constraint"),
),
Expand All @@ -472,6 +482,7 @@ class Meta:
"field_schema_ids",
"case_insensitive",
"nulls_distinct",
"condition",
"description",
)

Expand All @@ -485,6 +496,29 @@ def __init__(self, *args, **kwargs):
)
if self.instance.pk:
self.initial["field_schema_ids"] = [f.pk for f in self.instance.get_fields()]
# The model stores fields by schema ID; the form shows and accepts field names.
if self.instance.condition and not self.is_bound:
names = {f.schema_id: f.name for f in self._cot_fields()}
self.initial["condition"] = [
{**p, "field": names.get(p.get("field"), p.get("field"))} for p in self.instance.condition
]

def _cot_fields(self):
return CustomObjectTypeField.objects.filter(custom_object_type_id=self.instance.custom_object_type_id)

def clean_field_schema_ids(self):
return [f.schema_id for f in self.cleaned_data["field_schema_ids"]]

def clean_condition(self):
condition = self.cleaned_data["condition"]
if not condition:
return []
if not isinstance(condition, list) or not all(isinstance(p, dict) for p in condition):
raise forms.ValidationError(_("The condition must be a list of predicates."))
schema_ids = {f.name: f.schema_id for f in self._cot_fields()}
unknown = [str(p.get("field")) for p in condition if p.get("field") not in schema_ids]
if unknown:
raise forms.ValidationError(
_("Unknown field(s): {names}").format(names=", ".join(unknown))
)
return [{**p, "field": schema_ids[p["field"]]} for p in condition]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.1.1 on 2026-10-10 01:41

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('netbox_custom_objects', '0019_customobjecttypeconstraint'),
]

operations = [
migrations.AddField(
model_name='customobjecttypeconstraint',
name='condition',
field=models.JSONField(blank=True, default=list),
),
]
Loading
Loading