Skip to content

Commit 6fbd046

Browse files
committed
Record-Type Option Data Form with Individual Fields
1 parent 2158566 commit 6fbd046

8 files changed

Lines changed: 507 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## 0.7.3 (2026-04-07)
4+
5+
### Added
6+
- **Record-Type Option Data Form with Individual Fields**
7+
- Record-type option definitions (e.g., `record(boolean, ipv4-address)`) now show individual form fields for each position in the record instead of a single Data text field
8+
- Boolean record fields render as a select dropdown (True/False)
9+
- String and other non-IP fields render as text inputs
10+
- IP-type fields within records get IPAM/DNS source selectors — single or multi-select based on `is_array` and position (only the last field in a KEA record can be an array)
11+
12+
- **Record-Type IP Source Linking**
13+
- New `record_manual_fields` JSONField on `OptionData` stores non-IP field values separately (e.g., `{"0": "true"}`)
14+
- `OptionData.to_kea_dict()` assembles the final data by interleaving manual fields and resolved IPs at correct positions (e.g., `"true, 10.0.0.1, 10.0.0.2"`)
15+
- `OptionDefinition` gains `parsed_record_types()` and `record_ip_field_index()` helper methods
16+
17+
### Changed
18+
- `OptionData.clean()` skips type validation for IP-position fields in record types — IPs come from validated NetBox objects and need no re-checking
19+
- Record-type options with commas in the data field no longer trigger the "not an array" validation error
20+
- API `data` field on OptionData now returns resolved data from `to_kea_dict()` instead of raw stored value — shows assembled record fields and resolved IP sources
21+
22+
### Migrations
23+
- `0005_optiondata_record_manual_fields` — adds `record_manual_fields` JSONField to `OptionData`
24+
325
## 0.7.2 (2026-04-01)
426

527
### Added

netbox_dhcp_kea_plugin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = """Łukasz Polański"""
44
__email__ = "wookasz@gmail.com"
5-
__version__ = "0.7.2"
5+
__version__ = "0.7.3"
66

77

88
from netbox.plugins import PluginConfig

netbox_dhcp_kea_plugin/api/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ class OptionDataSerializer(NetBoxModelSerializer):
194194
definition = NestedOptionDefinitionSerializer(read_only=True)
195195
vendor_option_space = NestedVendorOptionSpaceSerializer(read_only=True)
196196
ip_sources = OptionDataIPSourceSerializer(many=True, read_only=True)
197+
data = serializers.SerializerMethodField()
198+
199+
def get_data(self, obj):
200+
"""Return resolved data from to_kea_dict() for record/IP-source options."""
201+
return obj.to_kea_dict().get("data", obj.data)
197202

198203
class Meta:
199204
model = OptionData

netbox_dhcp_kea_plugin/forms.py

Lines changed: 133 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -595,68 +595,118 @@ def __init__(self, *args, **kwargs):
595595
}
596596
)
597597

598-
# Determine if IP source fields should be shown based on selected definition
599-
show_ip_sources = False
598+
# Determine form mode based on selected definition
599+
# Modes: "simple_ip" (plain ipv4/ipv6), "record" (individual fields per record type), "manual" (text data)
600+
self._record_mode = False
601+
self._record_types_list = []
602+
self._record_ip_index = None
603+
mode = "manual"
600604
is_array = True
601605
definition_id = get_field_value(self, "definition")
606+
defn = None
602607
if definition_id:
603608
try:
604609
defn = OptionDefinition.objects.get(pk=definition_id)
605-
show_ip_sources = defn.option_type == "ipv4-address"
606610
is_array = defn.is_array
611+
if defn.option_type in OptionDefinition.IP_TYPES:
612+
mode = "simple_ip"
613+
elif defn.option_type == "record" and defn.record_types:
614+
mode = "record"
607615
except OptionDefinition.DoesNotExist:
608616
pass
609617

610-
if not show_ip_sources:
618+
if mode == "manual":
611619
self.fields.pop("ipam_ip_sources", None)
612620
self.fields.pop("dns_record_sources", None)
613-
else:
614-
# Hide data field — IP sources replace manual data entry for IP-type options
621+
elif mode == "simple_ip":
622+
# Hide data field — IP sources replace manual data entry
615623
self.fields.pop("data", None)
616-
# For single-value options, use single-select instead of multi-select
617-
if not is_array:
618-
self.fields["ipam_ip_sources"] = DynamicModelChoiceField(
619-
queryset=IPAddress.objects.all(),
620-
required=False,
621-
label="IPAM IP Address",
622-
help_text="Select an IP address from NetBox IPAM. Overrides the Data field in KEA config output.",
623-
)
624-
625-
# Add DNS Record source field if netbox-dns integration is enabled
626-
if get_plugin_config("netbox_dhcp_kea_plugin", "enable_netbox_dns"):
627-
try:
628-
from netbox_dns.models import Record as DNSRecord
629-
630-
dns_query_params = {"type": ["A", "AAAA", "CNAME"]}
631-
if is_array:
632-
self.fields["dns_record_sources"] = DynamicModelMultipleChoiceField(
633-
queryset=DNSRecord.objects.all(),
634-
query_params=dns_query_params,
635-
required=False,
636-
label="DNS Records",
637-
help_text="Select DNS A/AAAA or CNAME records. IPs resolved at KEA config generation time.",
638-
)
639-
else:
640-
self.fields["dns_record_sources"] = DynamicModelChoiceField(
641-
queryset=DNSRecord.objects.all(),
642-
query_params=dns_query_params,
643-
required=False,
644-
label="DNS Record",
645-
help_text="Select a DNS A/AAAA or CNAME record. IP resolved at KEA config generation time.",
646-
)
647-
except ImportError:
648-
pass
649-
650-
# Pre-populate IP source fields for existing objects
624+
self._add_ip_source_fields(is_array)
651625
if self.instance.pk:
652626
self._populate_ip_source_fields()
627+
elif mode == "record":
628+
self._record_mode = True
629+
self._record_types_list = defn.parsed_record_types()
630+
self._record_ip_index = defn.record_ip_field_index()
631+
# Hide the plain data field — we build it from individual record fields
632+
self.fields.pop("data", None)
633+
# Add a field for each position in the record
634+
for i, type_name in enumerate(self._record_types_list):
635+
if type_name in OptionDefinition.IP_TYPES:
636+
continue # IP positions get source selectors below
637+
if type_name == "boolean":
638+
self.fields[f"record_field_{i}"] = forms.ChoiceField(
639+
choices=[("true", "True"), ("false", "False")],
640+
label=f"Field {i + 1} ({type_name})",
641+
required=False,
642+
help_text=f"Record field {i + 1}: {type_name}",
643+
)
644+
else:
645+
self.fields[f"record_field_{i}"] = forms.CharField(
646+
label=f"Field {i + 1} ({type_name})",
647+
required=False,
648+
help_text=f"Record field {i + 1}: {type_name}",
649+
)
650+
# Add IP source selectors if the record has an IP-type field
651+
if self._record_ip_index is not None:
652+
# Only the last field in a record can be an array in KEA
653+
ip_is_array = is_array and self._record_ip_index == len(self._record_types_list) - 1
654+
self._add_ip_source_fields(ip_is_array)
655+
else:
656+
self.fields.pop("ipam_ip_sources", None)
657+
self.fields.pop("dns_record_sources", None)
658+
# Pre-populate for existing objects
659+
if self.instance.pk:
660+
self._populate_ip_source_fields()
661+
662+
def _add_ip_source_fields(self, is_array):
663+
"""Add IPAM and DNS IP source selector fields."""
664+
if not is_array:
665+
self.fields["ipam_ip_sources"] = DynamicModelChoiceField(
666+
queryset=IPAddress.objects.all(),
667+
required=False,
668+
label="IPAM IP Address",
669+
help_text="Select an IP address from NetBox IPAM.",
670+
)
671+
# (multi-select is already defined as a class field; only override for single)
672+
673+
if get_plugin_config("netbox_dhcp_kea_plugin", "enable_netbox_dns"):
674+
try:
675+
from netbox_dns.models import Record as DNSRecord
676+
677+
dns_query_params = {"type": ["A", "AAAA", "CNAME"]}
678+
if is_array:
679+
self.fields["dns_record_sources"] = DynamicModelMultipleChoiceField(
680+
queryset=DNSRecord.objects.all(),
681+
query_params=dns_query_params,
682+
required=False,
683+
label="DNS Records",
684+
help_text="Select DNS A/AAAA or CNAME records. IPs resolved at KEA config generation time.",
685+
)
686+
else:
687+
self.fields["dns_record_sources"] = DynamicModelChoiceField(
688+
queryset=DNSRecord.objects.all(),
689+
query_params=dns_query_params,
690+
required=False,
691+
label="DNS Record",
692+
help_text="Select a DNS A/AAAA or CNAME record. IP resolved at KEA config generation time.",
693+
)
694+
except ImportError:
695+
pass
653696

654697
def _populate_ip_source_fields(self):
655698
"""Set initial values for IP source fields from existing OptionDataIPSource entries."""
656699
from django.contrib.contenttypes.models import ContentType
657700

658701
from netbox_dhcp_kea_plugin.models import OptionDataIPSource
659702

703+
# Populate record manual fields from stored JSON
704+
if self._record_mode and self.instance.record_manual_fields:
705+
for idx_str, value in self.instance.record_manual_fields.items():
706+
field_name = f"record_field_{idx_str}"
707+
if field_name in self.fields:
708+
self.initial[field_name] = value
709+
660710
sources = OptionDataIPSource.objects.filter(option_data=self.instance).order_by("ordinal")
661711
if not sources.exists():
662712
return
@@ -687,18 +737,34 @@ def fieldsets(self):
687737
name="Option Selection",
688738
),
689739
]
690-
# Build IP Sources or manual Data fieldset based on available fields
691-
ip_source_items = []
692-
if "ipam_ip_sources" in self.fields:
693-
ip_source_items.append("ipam_ip_sources")
694-
if "dns_record_sources" in self.fields:
695-
ip_source_items.append("dns_record_sources")
696-
if ip_source_items:
697-
base.append(
698-
FieldSet(*ip_source_items, InlineFields("always_send", "csv_format", label="Flags"), name="IP Sources")
699-
)
740+
# Build value fieldset based on form mode
741+
if self._record_mode:
742+
# Record mode: individual fields for each record position + IP source selectors
743+
record_items = []
744+
for i, type_name in enumerate(self._record_types_list):
745+
field_name = f"record_field_{i}"
746+
if field_name in self.fields:
747+
record_items.append(field_name)
748+
if "ipam_ip_sources" in self.fields:
749+
record_items.append("ipam_ip_sources")
750+
if "dns_record_sources" in self.fields:
751+
record_items.append("dns_record_sources")
752+
record_items.append(InlineFields("always_send", "csv_format", label="Flags"))
753+
base.append(FieldSet(*record_items, name="Record Fields"))
700754
else:
701-
base.append(FieldSet("data", InlineFields("always_send", "csv_format", label="Flags"), name="Value"))
755+
ip_source_items = []
756+
if "ipam_ip_sources" in self.fields:
757+
ip_source_items.append("ipam_ip_sources")
758+
if "dns_record_sources" in self.fields:
759+
ip_source_items.append("dns_record_sources")
760+
if ip_source_items:
761+
base.append(
762+
FieldSet(
763+
*ip_source_items, InlineFields("always_send", "csv_format", label="Flags"), name="IP Sources"
764+
)
765+
)
766+
else:
767+
base.append(FieldSet("data", InlineFields("always_send", "csv_format", label="Flags"), name="Value"))
702768
base.append(FieldSet("description", "tags", name="Metadata"))
703769
return tuple(base)
704770

@@ -711,6 +777,21 @@ def clean_csv_format(self):
711777
return csv_format
712778

713779
def save(self, *args, **kwargs):
780+
# Collect record manual fields into JSON before saving
781+
if self._record_mode:
782+
manual_fields = {}
783+
for i, type_name in enumerate(self._record_types_list):
784+
field_name = f"record_field_{i}"
785+
if field_name in self.cleaned_data:
786+
manual_fields[str(i)] = self.cleaned_data[field_name]
787+
self.instance.record_manual_fields = manual_fields or None
788+
if self._record_ip_index is not None:
789+
# Has IP sources — data will be assembled at config generation time
790+
self.instance.data = ""
791+
else:
792+
# No IP sources — assemble data now from manual fields
793+
parts = [manual_fields.get(str(i), "") for i in range(len(self._record_types_list))]
794+
self.instance.data = ", ".join(parts)
714795
instance = super().save(*args, **kwargs)
715796
self._save_ip_sources(instance)
716797
return instance
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
("netbox_dhcp_kea_plugin", "0004_optiondata_ip_source"),
7+
]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="optiondata",
12+
name="record_manual_fields",
13+
field=models.JSONField(
14+
blank=True,
15+
null=True,
16+
help_text="For record-type options with IP sources: JSON dict mapping field positions to manual values.",
17+
),
18+
),
19+
]

netbox_dhcp_kea_plugin/models.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,13 +1183,28 @@ def __str__(self):
11831183
def get_absolute_url(self):
11841184
return reverse("plugins:netbox_dhcp_kea_plugin:optiondefinition", args=[self.pk])
11851185

1186+
IP_TYPES = {"ipv4-address", "ipv6-address"}
1187+
11861188
@property
11871189
def space_name(self):
11881190
"""Return the effective space name for KEA config"""
11891191
if self.vendor_option_space:
11901192
return self.vendor_option_space.name
11911193
return self.option_space
11921194

1195+
def parsed_record_types(self):
1196+
"""Return a list of type strings from the comma-separated record_types field."""
1197+
if not self.record_types:
1198+
return []
1199+
return [t.strip() for t in self.record_types.split(",")]
1200+
1201+
def record_ip_field_index(self):
1202+
"""Return the index of the first IP-type field in record_types, or None."""
1203+
for i, t in enumerate(self.parsed_record_types()):
1204+
if t in self.IP_TYPES:
1205+
return i
1206+
return None
1207+
11931208
def to_kea_dict(self):
11941209
"""Return a dictionary representation for KEA option-def configuration.
11951210
@@ -1274,6 +1289,11 @@ class OptionData(NetBoxModel):
12741289
help_text="How to deliver this option: standard (direct), Option 43, or VIVSO (Option 125)",
12751290
)
12761291
data = models.TextField(blank=True, default="", help_text="Option value/data")
1292+
record_manual_fields = models.JSONField(
1293+
blank=True,
1294+
null=True,
1295+
help_text="For record-type options with IP sources: JSON dict mapping field positions to manual values.",
1296+
)
12771297
always_send = models.BooleanField(
12781298
default=False,
12791299
help_text="Always send this option even if not requested by client",
@@ -1339,10 +1359,22 @@ def clean(self):
13391359
self.vendor_option_space = self.definition.vendor_option_space
13401360

13411361
# Type-based validation of data against definition's option_type
1342-
if self.csv_format and self.data and self.definition:
1362+
# For record types with IP sources, validate only the manual fields — IP
1363+
# positions are filled from validated NetBox objects and need no re-checking.
1364+
if self.record_manual_fields and self.definition and self.definition.option_type == "record":
1365+
record_types = self.definition.parsed_record_types()
1366+
ip_index = self.definition.record_ip_field_index()
1367+
for idx_str, val in self.record_manual_fields.items():
1368+
i = int(idx_str)
1369+
if i == ip_index or not val or i >= len(record_types):
1370+
continue
1371+
self._validate_typed_value(val, record_types[i])
1372+
elif self.csv_format and self.data and self.definition:
13431373
values = [v.strip() for v in self.data.split(",")] if self.definition.is_array else [self.data.strip()]
1344-
# Single-value options must not contain commas
1345-
if not self.definition.is_array and "," in self.data:
1374+
# Single-value options must not contain commas, unless the definition
1375+
# is a "record" type where commas separate fields within a single value
1376+
# (e.g., slp-service-scope: "true, FPSLP, Unscoped").
1377+
if not self.definition.is_array and self.definition.option_type != "record" and "," in self.data:
13461378
raise ValidationError(
13471379
{"data": "This option does not accept multiple values (definition is not an array)."}
13481380
)
@@ -1470,7 +1502,28 @@ def to_kea_dict(self):
14701502

14711503
# Add data — resolve from IP sources if linked, otherwise use manual entry
14721504
ip_sources = self.ip_sources.order_by("ordinal")
1473-
if ip_sources.exists():
1505+
1506+
if self.record_manual_fields and self.definition and self.definition.option_type == "record":
1507+
# Record-type with IP sources: assemble from manual fields + resolved IPs
1508+
record_types = self.definition.parsed_record_types()
1509+
ip_index = self.definition.record_ip_field_index()
1510+
parts = []
1511+
for i, type_name in enumerate(record_types):
1512+
if i == ip_index:
1513+
# Resolve IP sources for this position
1514+
resolved = [self._resolve_ip(src.ip_source) for src in ip_sources]
1515+
seen = set()
1516+
for entry in resolved:
1517+
if not entry:
1518+
continue
1519+
for ip in entry.split(", "):
1520+
if ip not in seen:
1521+
seen.add(ip)
1522+
parts.append(ip)
1523+
else:
1524+
parts.append(self.record_manual_fields.get(str(i), ""))
1525+
result["data"] = ", ".join(parts)
1526+
elif ip_sources.exists():
14741527
resolved = [self._resolve_ip(src.ip_source) for src in ip_sources]
14751528
# Flatten (CNAME sources may resolve to multiple comma-separated IPs)
14761529
# and deduplicate while preserving order.

0 commit comments

Comments
 (0)