Upgrade models to full NetboxModel to allow UI/API interaction:

modified:   netbox_dns_bridge/models.py
deleted:    netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
new file:   netbox_dns_bridge/migrations/0003_full_netbox_models.py
This assumes the migration starts at v1.5.8.

Adjust model access and function calls to match new models:
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py

Update UI elements and permission interactions:
modified:   netbox_dns_bridge/filtersets.py
modified:   netbox_dns_bridge/forms.py
modified:   netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/navigation.py
modified:   netbox_dns_bridge/tables.py
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
This commit is contained in:
sven authored and Sven Luthi committed 2026-09-02 12:08:27 +02:00
1 parent a3f53767e4
commit a3bc64df62
18 files changed
+596 -352

No files matched your search

+37 -25
View File
@@ -3,32 +3,8 @@ from django.db.models import Q
import netbox_dns.models import netbox_dns.models
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from netbox_dns.choices import ZoneStatusChoices
from .models import CatalogZone from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class ZoneFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
status = django_filters.MultipleChoiceFilter(
choices=ZoneStatusChoices,
null_value=None,
)
class Meta:
model = netbox_dns.models.Zone
fields = ("id", "name", "view", "status", "catz_identifier")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(catz_identifier__name__icontains=value)
)
class CatalogZoneFilterSet(NetBoxModelFilterSet): class CatalogZoneFilterSet(NetBoxModelFilterSet):
@@ -45,3 +21,39 @@ class CatalogZoneFilterSet(NetBoxModelFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter(Q(view__name__icontains=value)) return queryset.filter(Q(view__name__icontains=value))
class CatalogZoneMemberFilterSet(NetBoxModelFilterSet):
catalog_zone = django_filters.ModelMultipleChoiceFilter(
field_name="catalog_zone",
queryset=CatalogZone.objects.all(),
)
class Meta:
model = CatalogZoneMember
fields = ("id", "catalog_zone", "zone")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) | Q(zone__name__icontains=value)
)
class SeenTransferClientFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
class Meta:
model = SeenTransferClient
fields = ("id", "view", "source_ip")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(source_ip__icontains=value) | Q(view__name__icontains=value)
)
+21 -16
View File
@@ -3,10 +3,9 @@ from django.utils.translation import gettext_lazy as _
import netbox_dns.models import netbox_dns.models
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
from netbox_dns.choices import ZoneStatusChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from .models import CatalogZone from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneForm(NetBoxModelForm): class CatalogZoneForm(NetBoxModelForm):
@@ -35,20 +34,6 @@ class CatalogZoneForm(NetBoxModelForm):
) )
class CatalogZoneMemberFilterForm(NetBoxModelFilterSetForm):
model = netbox_dns.models.Zone
q = forms.CharField(required=False, label=_("Search"))
view = DynamicModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False,
)
status = forms.MultipleChoiceField(
choices=ZoneStatusChoices,
required=False,
)
class CatalogZoneFilterForm(NetBoxModelFilterSetForm): class CatalogZoneFilterForm(NetBoxModelFilterSetForm):
model = CatalogZone model = CatalogZone
@@ -57,3 +42,23 @@ class CatalogZoneFilterForm(NetBoxModelFilterSetForm):
queryset=netbox_dns.models.View.objects.all(), queryset=netbox_dns.models.View.objects.all(),
required=False, required=False,
) )
class CatalogZoneMemberFilterForm(NetBoxModelFilterSetForm):
model = CatalogZoneMember
q = forms.CharField(required=False, label=_("Search"))
catalog_zone = forms.ModelMultipleChoiceField(
queryset=CatalogZone.objects.all(),
required=False,
)
class SeenTransferClientFilterForm(NetBoxModelFilterSetForm):
model = SeenTransferClient
q = forms.CharField(required=False, label=_("Search"))
view = forms.ModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False,
)
+2 -2
View File
@@ -12,7 +12,7 @@ from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClients, CatalogZone from netbox_dns_bridge.models import SeenTransferClient, CatalogZone
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@@ -111,7 +111,7 @@ class SendClientNotify(JobRunner):
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = list( clients = list(
SeenTransferClients.objects.filter( SeenTransferClient.objects.filter(
view=view, last_seen__gte=seen_cutoff view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True) ).values_list("source_ip", flat=True)
) )
@@ -4,7 +4,7 @@ import dns.rdatatype
import dns.rdataclass import dns.rdataclass
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone, View as NBView from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember
from netbox_dns_bridge.jobs import notify from netbox_dns_bridge.jobs import notify
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
@@ -63,7 +63,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# get zones from netbox # get zones from netbox
nb_zones = NBZone.objects.filter( nb_zones = NBZone.objects.filter(
view__name=view_name, active=True view__name=view_name, active=True
).select_related("catz_identifier") ).select_related("catalog_zone_member")
ptr_base = dns.name.from_text("zones", origin) ptr_base = dns.name.from_text("zones", origin)
@@ -73,21 +73,21 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# Create PTR record # Create PTR record
try: try:
catz_identifier = nb_zone.catz_identifier member = nb_zone.catalog_zone_member
except NBZone.catz_identifier.RelatedObjectDoesNotExist: except NBZone.catalog_zone_member.RelatedObjectDoesNotExist:
catalog_zone = _get_or_create_catalog_zone(nb_zone.view) catalog_zone = _get_or_create_catalog_zone(nb_zone.view)
catz_identifier = CatalogZoneMemberIdentifier.objects.create( member = CatalogZoneMember.objects.create(
zone=nb_zone, zone=nb_zone,
name=_generate_member_identifier(), name=_generate_member_name(),
catalog_zone=catalog_zone, catalog_zone=catalog_zone,
) )
p_name = catz_identifier.name p_name = member.name
ptr_name = dns.name.from_text(p_name, ptr_base) ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin): if not ptr_name.is_subdomain(origin):
raise ValueError( raise ValueError(
f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain" f"Catalog zone member {ptr_name.to_text()} not a subdomain"
) )
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -132,7 +132,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None return None
def _generate_member_identifier() -> str: def _generate_member_name() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
@@ -143,18 +143,18 @@ def _get_or_create_catalog_zone(view: NBView) -> CatalogZone:
return catalog_zone return catalog_zone
def update_member_identifier(zone: NBZone) -> None: def update_member(zone: NBZone) -> None:
try: try:
catalog_zone = _get_or_create_catalog_zone(zone.view) catalog_zone = _get_or_create_catalog_zone(zone.view)
CatalogZoneMemberIdentifier.objects.update_or_create( CatalogZoneMember.objects.update_or_create(
zone=zone, zone=zone,
defaults={ defaults={
"name": _generate_member_identifier(), "name": _generate_member_name(),
"catalog_zone": catalog_zone, "catalog_zone": catalog_zone,
}, },
) )
except OperationalError as e: except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}") LOGGER.error(f"ERROR: Failed to update Catalog Zone member: {e}")
close_old_connections() close_old_connections()
@@ -15,7 +15,7 @@ from django.db import close_old_connections, OperationalError
from django.conf import settings from django.conf import settings
from netbox_dns.models import Zone, View from netbox_dns.models import Zone, View
from . import catalog_zone_manager as catzm from . import catalog_zone_manager as catzm
from netbox_dns_bridge.models import SeenTransferClients from netbox_dns_bridge.models import SeenTransferClient
from netbox_dns_bridge.utils import build_dns_zone, get_logger from netbox_dns_bridge.utils import build_dns_zone, get_logger
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
@@ -77,7 +77,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
def _track_seen_client(self, source_ip: str, view: View) -> None: def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False): if SETTINGS.get("notify_clients", False):
try: try:
SeenTransferClients.objects.update_or_create( SeenTransferClient.objects.update_or_create(
source_ip=source_ip, source_ip=source_ip,
view=view, view=view,
defaults={"last_seen": timezone.now()}, defaults={"last_seen": timezone.now()},
@@ -1,146 +0,0 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_serials_and_link_identifiers(apps, schema_editor):
"""Seed one CatalogZone per view from the old global serial and link identifiers."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMemberIdentifier = apps.get_model(
"netbox_dns_bridge", "CatalogZoneMemberIdentifier"
)
# Preserve the current global serial; default to 1 when never initialized.
seed_serial = 1
kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first()
if kv is not None:
seed_serial = kv.value
# Create a CatalogZone for every view that currently has at least one
# member identifier, seeding each with the old global serial.
view_ids = set(
CatalogZoneMemberIdentifier.objects.exclude(
zone__view__isnull=True
).values_list("zone__view_id", flat=True)
)
view_id_to_catalog_zone = {}
for view_id in view_ids:
cz, _ = CatalogZone.objects.get_or_create(
view_id=view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[view_id] = cz
# Link each identifier to the CatalogZone matching its zone's view.
for ident in CatalogZoneMemberIdentifier.objects.select_related("zone__view"):
cz = view_id_to_catalog_zone.get(ident.zone.view_id)
if cz is None:
# Defensive: identifier existed without a resolvable view. Create
# the CatalogZone on demand so the NOT NULL constraint can be set.
cz, _ = CatalogZone.objects.get_or_create(
view_id=ident.zone.view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[ident.zone.view_id] = cz
ident.catalog_zone = cz
ident.save(update_fields=["catalog_zone"])
def reverse_migration(apps, schema_editor):
"""Best-effort reverse: restore the KV row and null the FK."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMemberIdentifier = apps.get_model(
"netbox_dns_bridge", "CatalogZoneMemberIdentifier"
)
# Pick a representative serial (first catalog zone) to write back.
first = CatalogZone.objects.order_by("pk").first()
serial = first.soa_serial if first is not None else 1
IntegerKeyValueSetting.objects.update_or_create(
key="catalog-zone-soa-serial", defaults={"value": serial}
)
CatalogZoneMemberIdentifier.objects.update(catalog_zone=None)
class Migration(migrations.Migration):
dependencies = [
(
"netbox_dns",
"0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more",
),
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
migrations.CreateModel(
name="CatalogZone",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("soa_serial", models.IntegerField(default=1)),
("soa_refresh", models.IntegerField(default=60)),
("soa_retry", models.IntegerField(default=10)),
("soa_expire", models.IntegerField(default=1209600)),
("soa_minimum", models.IntegerField(default=0)),
(
"view",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone",
to="netbox_dns.view",
),
),
],
options={
"ordering": ("view__name",),
},
),
migrations.AddField(
model_name="catalogzonememberidentifier",
name="catalog_zone",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="member_identifiers",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AlterField(
model_name="catalogzonememberidentifier",
name="name",
field=models.CharField(max_length=26),
),
migrations.RunPython(
copy_serials_and_link_identifiers,
reverse_code=reverse_migration,
),
migrations.AlterField(
model_name="catalogzonememberidentifier",
name="catalog_zone",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="member_identifiers",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AddConstraint(
model_name="catalogzonememberidentifier",
constraint=models.UniqueConstraint(
fields=("name", "catalog_zone"),
name="unique_name_per_catalog_zone",
),
),
migrations.DeleteModel(
name="IntegerKeyValueSetting",
),
]
@@ -0,0 +1,233 @@
import taggit.managers
import utilities.json
import django.db.models.deletion
from django.db import migrations, models
def copy_serials_and_link_members(apps, schema_editor):
"""Seed one CatalogZone per view from the old global serial and link members."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Preserve the current global serial; default to 1 when never initialized.
seed_serial = 1
kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first()
if kv is not None:
seed_serial = kv.value
# Create a CatalogZone for every view that currently has at least one
# member, seeding each with the old global serial.
view_ids = set(
CatalogZoneMember.objects.exclude(
zone__view__isnull=True
).values_list("zone__view_id", flat=True)
)
view_id_to_catalog_zone = {}
for view_id in view_ids:
cz, _ = CatalogZone.objects.get_or_create(
view_id=view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[view_id] = cz
# Link each member to the CatalogZone matching its zone's view.
for member in CatalogZoneMember.objects.select_related("zone__view"):
cz = view_id_to_catalog_zone.get(member.zone.view_id)
if cz is None:
# Defensive: member existed without a resolvable view. Create
# the CatalogZone on demand so the NOT NULL constraint can be set.
cz, _ = CatalogZone.objects.get_or_create(
view_id=member.zone.view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[member.zone.view_id] = cz
member.catalog_zone = cz
member.save(update_fields=["catalog_zone"])
def reverse_migration(apps, schema_editor):
"""Best-effort reverse: restore the KV row and null the FK."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Pick a representative serial (first catalog zone) to write back.
first = CatalogZone.objects.order_by("pk").first()
serial = first.soa_serial if first is not None else 1
IntegerKeyValueSetting.objects.update_or_create(
key="catalog-zone-soa-serial", defaults={"value": serial}
)
CatalogZoneMember.objects.update(catalog_zone=None)
class Migration(migrations.Migration):
dependencies = [
("extras", "0140_imageattachment_image_size"),
(
"netbox_dns",
"0032_record_expiration_date",
),
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
# --- CatalogZone (full NetBoxModel, born with all fields) ---
migrations.CreateModel(
name="CatalogZone",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("soa_serial", models.IntegerField(default=1)),
("soa_refresh", models.IntegerField(default=60)),
("soa_retry", models.IntegerField(default=10)),
("soa_expire", models.IntegerField(default=1209600)),
("soa_minimum", models.IntegerField(default=0)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
(
"custom_field_data",
models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
(
"tags",
taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
(
"view",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone",
to="netbox_dns.view",
),
),
],
options={
"ordering": ("view__name",),
},
),
# --- CatalogZoneMemberIdentifier → CatalogZoneMember (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="CatalogZoneMemberIdentifier",
new_name="CatalogZoneMember",
),
migrations.AddField(
model_name="catalogzonemember",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="catalogzonemember",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
migrations.AlterField(
model_name="catalogzonemember",
name="name",
field=models.CharField(max_length=26),
),
migrations.AlterField(
model_name="catalogzonemember",
name="zone",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone_member",
to="netbox_dns.zone",
),
),
migrations.AddField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.RunPython(
copy_serials_and_link_members,
reverse_code=reverse_migration,
),
migrations.AlterField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AddConstraint(
model_name="catalogzonemember",
constraint=models.UniqueConstraint(
fields=("name", "catalog_zone"),
name="unique_name_per_catalog_zone",
),
),
migrations.DeleteModel(
name="IntegerKeyValueSetting",
),
# --- SeenTransferClients → SeenTransferClient (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="SeenTransferClients",
new_name="SeenTransferClient",
),
migrations.AddField(
model_name="seentransferclient",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="seentransferclient",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
]
+12 -15
View File
@@ -1,13 +1,11 @@
from django.db import models from django.db import models
from django.urls import reverse
# import netbox.models # import netbox.models
import netbox_dns.models import netbox_dns.models
from netbox.models import NetBoxModel
class CatalogZone(models.Model): class CatalogZone(NetBoxModel):
_netbox_private = True
view = models.OneToOneField( view = models.OneToOneField(
to=netbox_dns.models.View, to=netbox_dns.models.View,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -21,17 +19,14 @@ class CatalogZone(models.Model):
class Meta: class Meta:
ordering = ("view__name",) ordering = ("view__name",)
verbose_name = "Catalog Zone"
verbose_name_plural = "Catalog Zones"
def __str__(self): def __str__(self):
return f"{self.view.name}: {self.soa_serial}" return f"{self.view.name}: {self.soa_serial}"
def get_absolute_url(self):
return reverse("plugins:netbox_dns_bridge:catalogzone_edit", args=[self.pk])
class CatalogZoneMemberIdentifier(models.Model):
_netbox_private = True
class CatalogZoneMember(NetBoxModel):
name = models.CharField( name = models.CharField(
max_length=26, max_length=26,
) )
@@ -39,17 +34,19 @@ class CatalogZoneMemberIdentifier(models.Model):
zone = models.OneToOneField( zone = models.OneToOneField(
to=netbox_dns.models.Zone, to=netbox_dns.models.Zone,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="catz_identifier", related_name="catalog_zone_member",
) )
catalog_zone = models.ForeignKey( catalog_zone = models.ForeignKey(
to=CatalogZone, to=CatalogZone,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="member_identifiers", related_name="members",
) )
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)
verbose_name = "Catalog Zone Member"
verbose_name_plural = "Catalog Zone Members"
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["name", "catalog_zone"], fields=["name", "catalog_zone"],
@@ -61,9 +58,7 @@ class CatalogZoneMemberIdentifier(models.Model):
return self.name return self.name
class SeenTransferClients(models.Model): class SeenTransferClient(NetBoxModel):
_netbox_private = True
source_ip = models.GenericIPAddressField() source_ip = models.GenericIPAddressField()
last_seen = models.DateTimeField() last_seen = models.DateTimeField()
view = models.ForeignKey( view = models.ForeignKey(
@@ -77,6 +72,8 @@ class SeenTransferClients(models.Model):
class Meta: class Meta:
ordering = ("source_ip",) ordering = ("source_ip",)
verbose_name = "Seen Transfer Client"
verbose_name_plural = "Seen Transfer Clients"
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["source_ip", "view"], fields=["source_ip", "view"],
+14 -13
View File
@@ -2,32 +2,33 @@ from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem from netbox.plugins import PluginMenu, PluginMenuItem
catalog_zones_menu_item = PluginMenuItem( catalog_zones_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catalog_zones", link="plugins:netbox_dns_bridge:catalogzone_list",
link_text=_("Catalog Zones"), link_text=_("Catalog Zones"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzone"],
staff_only=True,
) )
catalog_zone_members_menu_item = PluginMenuItem( catalog_zone_members_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catalog_zone_members", link="plugins:netbox_dns_bridge:catalogzonemember_list",
link_text=_("Catalog Zone Members"), link_text=_("Catalog Zone Members"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzonemember"],
staff_only=True,
) )
notify_menu_item = PluginMenuItem( seen_transfer_clients_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:notify", link="plugins:netbox_dns_bridge:seentransferclient_list",
link_text=_("Notify"), link_text=_("Clients"),
permissions=[], permissions=["netbox_dns_bridge.view_seentransferclient"],
staff_only=True,
) )
menu = PluginMenu( menu = PluginMenu(
label=_("DNS Bridge"), label=_("DNS Bridge"),
groups=( groups=(
( (
_("Information"), _("Catalog Zone"),
(catalog_zones_menu_item, catalog_zone_members_menu_item, notify_menu_item), (catalog_zones_menu_item, catalog_zone_members_menu_item),
),
(
_("Notify"),
(seen_transfer_clients_menu_item,),
), ),
), ),
icon_class="mdi mdi-bridge", icon_class="mdi mdi-bridge",
@@ -77,15 +77,15 @@ def zone_post_save(sender, instance, created, **kwargs):
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
if created: if created:
# If zone was created, create catz member identifier record for this zone. # If zone was created, create the catalog zone member record for this zone.
catzm.update_member_identifier(zone) catzm.update_member(zone)
else: else:
# Re-create the catz member identifier when the zone name or its view # Re-create the catalog zone member when the zone name or its view
# changed. A view move leaves the identifier pointing at the wrong # changed. A view move leaves the member pointing at the wrong
# catalog zone, so it must be refreshed. # catalog zone, so it must be refreshed.
old_name = getattr(zone, "_old_name", None) old_name = getattr(zone, "_old_name", None)
if (old_name and zone.name != old_name) or view_changed: if (old_name and zone.name != old_name) or view_changed:
catzm.update_member_identifier(zone) catzm.update_member(zone)
@receiver(post_delete, sender=Zone) @receiver(post_delete, sender=Zone)
+32 -12
View File
@@ -1,29 +1,30 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import netbox_dns.models
from netbox.tables import NetBoxTable from netbox.tables import NetBoxTable
from netbox.tables.columns import ActionsColumn
from .models import CatalogZone from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneMemberTable(NetBoxTable): class CatalogZoneMemberTable(NetBoxTable):
name = tables.Column(linkify=True) name = tables.Column(linkify=True)
zone = tables.Column(
accessor="zone__name",
verbose_name=_("Zone"),
linkify=True,
)
view = tables.Column( view = tables.Column(
accessor="view__name", accessor="catalog_zone__view__name",
verbose_name=_("View"), verbose_name=_("View"),
) )
status = tables.Column(verbose_name=_("Status"))
catz_identifier = tables.Column( actions = ActionsColumn(actions=())
accessor="catz_identifier__name",
verbose_name=_("Catalog Zone Identifier"),
default="—",
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = netbox_dns.models.Zone model = CatalogZoneMember
fields = ("name", "view", "status", "catz_identifier") fields = ("name", "zone", "view")
default_columns = ("name", "view", "status", "catz_identifier") default_columns = ("name", "zone", "view")
class CatalogZoneTable(NetBoxTable): class CatalogZoneTable(NetBoxTable):
@@ -38,6 +39,8 @@ class CatalogZoneTable(NetBoxTable):
soa_expire = tables.Column(verbose_name=_("Expire")) soa_expire = tables.Column(verbose_name=_("Expire"))
soa_minimum = tables.Column(verbose_name=_("Minimum")) soa_minimum = tables.Column(verbose_name=_("Minimum"))
actions = ActionsColumn(actions=("edit", "changelog"))
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CatalogZone model = CatalogZone
fields = ( fields = (
@@ -56,3 +59,20 @@ class CatalogZoneTable(NetBoxTable):
"soa_expire", "soa_expire",
"soa_minimum", "soa_minimum",
) )
class SeenTransferClientTable(NetBoxTable):
source_ip = tables.Column(linkify=True)
view = tables.Column(
accessor="view__name",
verbose_name=_("View"),
linkify=True,
)
last_seen = tables.DateTimeColumn(verbose_name=_("Last Seen"))
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = SeenTransferClient
fields = ("source_ip", "view", "last_seen")
default_columns = ("source_ip", "view", "last_seen")
@@ -1,4 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zone Members" %}{% endblock %}
@@ -0,0 +1,50 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "SOA Serial" %}</th>
<td>{{ object.soa_serial }}</td>
</tr>
<tr>
<th scope="row">{% trans "Refresh" %}</th>
<td>{{ object.soa_refresh }}</td>
</tr>
<tr>
<th scope="row">{% trans "Retry" %}</th>
<td>{{ object.soa_retry }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expire" %}</th>
<td>{{ object.soa_expire }}</td>
</tr>
<tr>
<th scope="row">{% trans "Minimum" %}</th>
<td>{{ object.soa_minimum }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone Member" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Zone" %}</th>
<td>{{ object.zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Catalog Zone" %}</th>
<td>{{ object.catalog_zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.catalog_zone.view|linkify }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,37 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}DNS Bridge Notify{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="card mb-3">
<h5 class="card-header">Seen Transfer Clients</h5>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>Source IP</th>
<th>View</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{% for c in seen_clients %}
<tr>
<td>{{ c.source_ip }}</td>
<td>{{ c.view.name }}</td>
<td>{{ c.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-muted">No seen transfer clients.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Seen Transfer Client" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Source IP" %}</th>
<td>{{ object.source_ip }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Seen" %}</th>
<td>{{ object.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+35 -11
View File
@@ -1,23 +1,47 @@
from django.urls import path from django.urls import include, path
from .views import ( from utilities.urls import get_model_urls
# Import views so register_model_view decorators run.
from .views import ( # noqa: F401
CatalogZoneEditView, CatalogZoneEditView,
CatalogZoneListView, CatalogZoneListView,
CatalogZoneMembersView, CatalogZoneMemberListView,
NotifyOverviewView, CatalogZoneMemberView,
CatalogZoneView,
SeenTransferClientListView,
SeenTransferClientView,
) )
urlpatterns = [ urlpatterns = [
path("catalog-zones/", CatalogZoneListView.as_view(), name="catalog_zones"),
path( path(
"catalog-zones/<int:pk>/edit/", "catalog-zones/",
CatalogZoneEditView.as_view(), include(
name="catalogzone_edit", get_model_urls("netbox_dns_bridge", "catalogzone", detail=False)
),
),
path(
"catalog-zones/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzone")),
), ),
path( path(
"catalog-zone-members/", "catalog-zone-members/",
CatalogZoneMembersView.as_view(), include(
name="catalog_zone_members", get_model_urls("netbox_dns_bridge", "catalogzonemember", detail=False)
),
),
path(
"catalog-zone-members/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzonemember")),
),
path(
"seen-transfer-clients/",
include(
get_model_urls("netbox_dns_bridge", "seentransferclient", detail=False)
),
),
path(
"seen-transfer-clients/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "seentransferclient")),
), ),
path("notify/", NotifyOverviewView.as_view(), name="notify"),
] ]
+60 -51
View File
@@ -1,67 +1,76 @@
from django.contrib.auth.decorators import user_passes_test from netbox.object_actions import EditObject
from django.shortcuts import render from netbox.views import generic
from django.utils.decorators import method_decorator from utilities.views import register_model_view
from django.views import View
import netbox_dns.models from .filtersets import (
from netbox.views.generic import ObjectEditView, ObjectListView CatalogZoneFilterSet,
CatalogZoneMemberFilterSet,
from .filtersets import CatalogZoneFilterSet, ZoneFilterSet SeenTransferClientFilterSet,
)
from .forms import ( from .forms import (
CatalogZoneFilterForm, CatalogZoneFilterForm,
CatalogZoneForm, CatalogZoneForm,
CatalogZoneMemberFilterForm, CatalogZoneMemberFilterForm,
SeenTransferClientFilterForm,
)
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
from .tables import (
CatalogZoneMemberTable,
CatalogZoneTable,
SeenTransferClientTable,
) )
from .models import CatalogZone, SeenTransferClients
from .tables import CatalogZoneMemberTable, CatalogZoneTable
@method_decorator( @register_model_view(CatalogZone, "list", path="", detail=False)
user_passes_test(lambda u: u.is_superuser), name="dispatch" class CatalogZoneListView(generic.ObjectListView):
)
class CatalogZoneListView(ObjectListView):
queryset = CatalogZone.objects.select_related("view").order_by("view__name") queryset = CatalogZone.objects.select_related("view").order_by("view__name")
table = CatalogZoneTable table = CatalogZoneTable
filterset = CatalogZoneFilterSet filterset = CatalogZoneFilterSet
filterset_form = CatalogZoneFilterForm filterset_form = CatalogZoneFilterForm
template_name = "netbox_dns_bridge/catalog_zone_list.html"
actions = ("edit", "export")
@method_decorator(
user_passes_test(lambda u: u.is_superuser), name="dispatch"
)
class CatalogZoneEditView(ObjectEditView):
queryset = CatalogZone.objects.select_related("view")
form = CatalogZoneForm
template_name = "netbox_dns_bridge/catalog_zone_edit.html"
@method_decorator(
user_passes_test(lambda u: u.is_superuser), name="dispatch"
)
class CatalogZoneMembersView(ObjectListView):
queryset = netbox_dns.models.Zone.objects.select_related(
"view", "catz_identifier"
).order_by("view__name", "name")
table = CatalogZoneMemberTable
filterset = ZoneFilterSet
filterset_form = CatalogZoneMemberFilterForm
template_name = "netbox_dns_bridge/catalog_zone_members.html"
actions = () actions = ()
@method_decorator( @register_model_view(CatalogZone)
user_passes_test(lambda u: u.is_superuser), name="dispatch" class CatalogZoneView(generic.ObjectView):
) queryset = CatalogZone.objects.select_related("view")
class NotifyOverviewView(View): actions = (EditObject,)
template_name = "netbox_dns_bridge/notify_overview.html"
def get(self, request):
seen_clients = ( @register_model_view(CatalogZone, "edit")
SeenTransferClients.objects.select_related("view") class CatalogZoneEditView(generic.ObjectEditView):
.all() queryset = CatalogZone.objects.select_related("view")
.order_by("source_ip") form = CatalogZoneForm
)
context = {"seen_clients": seen_clients}
return render(request, self.template_name, context) @register_model_view(CatalogZoneMember, "list", path="", detail=False)
class CatalogZoneMemberListView(generic.ObjectListView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
).order_by("catalog_zone__view__name", "name")
table = CatalogZoneMemberTable
filterset = CatalogZoneMemberFilterSet
filterset_form = CatalogZoneMemberFilterForm
actions = ()
@register_model_view(CatalogZoneMember)
class CatalogZoneMemberView(generic.ObjectView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
)
actions = ()
@register_model_view(SeenTransferClient, "list", path="", detail=False)
class SeenTransferClientListView(generic.ObjectListView):
queryset = SeenTransferClient.objects.select_related("view").order_by("source_ip")
table = SeenTransferClientTable
filterset = SeenTransferClientFilterSet
filterset_form = SeenTransferClientFilterForm
actions = ()
@register_model_view(SeenTransferClient)
class SeenTransferClientView(generic.ObjectView):
queryset = SeenTransferClient.objects.select_related("view")
actions = ()