diff --git a/netbox_dns_bridge/filtersets.py b/netbox_dns_bridge/filtersets.py index 87de3a6..fb77cf8 100644 --- a/netbox_dns_bridge/filtersets.py +++ b/netbox_dns_bridge/filtersets.py @@ -3,32 +3,8 @@ from django.db.models import Q import netbox_dns.models from netbox.filtersets import NetBoxModelFilterSet -from netbox_dns.choices import ZoneStatusChoices -from .models import CatalogZone - - -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) - ) +from .models import CatalogZone, CatalogZoneMember, SeenTransferClient class CatalogZoneFilterSet(NetBoxModelFilterSet): @@ -45,3 +21,39 @@ class CatalogZoneFilterSet(NetBoxModelFilterSet): if not value.strip(): return queryset 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) + ) diff --git a/netbox_dns_bridge/forms.py b/netbox_dns_bridge/forms.py index dd7d784..7225ea3 100644 --- a/netbox_dns_bridge/forms.py +++ b/netbox_dns_bridge/forms.py @@ -3,10 +3,9 @@ from django.utils.translation import gettext_lazy as _ import netbox_dns.models from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm -from netbox_dns.choices import ZoneStatusChoices from utilities.forms.fields import DynamicModelMultipleChoiceField -from .models import CatalogZone +from .models import CatalogZone, CatalogZoneMember, SeenTransferClient 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): model = CatalogZone @@ -57,3 +42,23 @@ class CatalogZoneFilterForm(NetBoxModelFilterSetForm): queryset=netbox_dns.models.View.objects.all(), 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, + ) diff --git a/netbox_dns_bridge/jobs/notify.py b/netbox_dns_bridge/jobs/notify.py index 128805b..1598647 100644 --- a/netbox_dns_bridge/jobs/notify.py +++ b/netbox_dns_bridge/jobs/notify.py @@ -12,7 +12,7 @@ from django.db import close_old_connections, OperationalError from netbox.jobs import JobRunner from netbox_dns.models import View, Zone, Record 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__) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) @@ -111,7 +111,7 @@ class SendClientNotify(JobRunner): seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) clients = list( - SeenTransferClients.objects.filter( + SeenTransferClient.objects.filter( view=view, last_seen__gte=seen_cutoff ).values_list("source_ip", flat=True) ) diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py index 919a753..a565de3 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py @@ -4,7 +4,7 @@ import dns.rdatatype import dns.rdataclass from netbox_dns_bridge.utils import get_logger 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 uuid import uuid4 from base64 import b32encode @@ -63,7 +63,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: # get zones from netbox nb_zones = NBZone.objects.filter( view__name=view_name, active=True - ).select_related("catz_identifier") + ).select_related("catalog_zone_member") ptr_base = dns.name.from_text("zones", origin) @@ -73,21 +73,21 @@ def create_zone(name, view_name) -> dns.zone.Zone: # Create PTR record try: - catz_identifier = nb_zone.catz_identifier - except NBZone.catz_identifier.RelatedObjectDoesNotExist: + member = nb_zone.catalog_zone_member + except NBZone.catalog_zone_member.RelatedObjectDoesNotExist: catalog_zone = _get_or_create_catalog_zone(nb_zone.view) - catz_identifier = CatalogZoneMemberIdentifier.objects.create( + member = CatalogZoneMember.objects.create( zone=nb_zone, - name=_generate_member_identifier(), + name=_generate_member_name(), catalog_zone=catalog_zone, ) - p_name = catz_identifier.name + p_name = member.name ptr_name = dns.name.from_text(p_name, ptr_base) if not ptr_name.is_subdomain(origin): 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( dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() @@ -132,7 +132,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: return None -def _generate_member_identifier() -> str: +def _generate_member_name() -> str: 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 -def update_member_identifier(zone: NBZone) -> None: +def update_member(zone: NBZone) -> None: try: catalog_zone = _get_or_create_catalog_zone(zone.view) - CatalogZoneMemberIdentifier.objects.update_or_create( + CatalogZoneMember.objects.update_or_create( zone=zone, defaults={ - "name": _generate_member_identifier(), + "name": _generate_member_name(), "catalog_zone": catalog_zone, }, ) 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() diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py index d38b0f1..0f725b3 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py @@ -15,7 +15,7 @@ from django.db import close_old_connections, OperationalError from django.conf import settings from netbox_dns.models import Zone, View 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 LOGGER = get_logger(__name__) @@ -77,7 +77,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): def _track_seen_client(self, source_ip: str, view: View) -> None: if SETTINGS.get("notify_clients", False): try: - SeenTransferClients.objects.update_or_create( + SeenTransferClient.objects.update_or_create( source_ip=source_ip, view=view, defaults={"last_seen": timezone.now()}, diff --git a/netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py b/netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py deleted file mode 100644 index a8d8ae3..0000000 --- a/netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py +++ /dev/null @@ -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", - ), - ] diff --git a/netbox_dns_bridge/migrations/0003_full_netbox_models.py b/netbox_dns_bridge/migrations/0003_full_netbox_models.py new file mode 100644 index 0000000..62376fa --- /dev/null +++ b/netbox_dns_bridge/migrations/0003_full_netbox_models.py @@ -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" + ), + ), + ] diff --git a/netbox_dns_bridge/models.py b/netbox_dns_bridge/models.py index 5eb479e..cd41b4e 100644 --- a/netbox_dns_bridge/models.py +++ b/netbox_dns_bridge/models.py @@ -1,13 +1,11 @@ from django.db import models -from django.urls import reverse # import netbox.models import netbox_dns.models +from netbox.models import NetBoxModel -class CatalogZone(models.Model): - _netbox_private = True - +class CatalogZone(NetBoxModel): view = models.OneToOneField( to=netbox_dns.models.View, on_delete=models.CASCADE, @@ -21,17 +19,14 @@ class CatalogZone(models.Model): class Meta: ordering = ("view__name",) + verbose_name = "Catalog Zone" + verbose_name_plural = "Catalog Zones" def __str__(self): 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( max_length=26, ) @@ -39,17 +34,19 @@ class CatalogZoneMemberIdentifier(models.Model): zone = models.OneToOneField( to=netbox_dns.models.Zone, on_delete=models.CASCADE, - related_name="catz_identifier", + related_name="catalog_zone_member", ) catalog_zone = models.ForeignKey( to=CatalogZone, on_delete=models.CASCADE, - related_name="member_identifiers", + related_name="members", ) class Meta: ordering = ("name",) + verbose_name = "Catalog Zone Member" + verbose_name_plural = "Catalog Zone Members" constraints = [ models.UniqueConstraint( fields=["name", "catalog_zone"], @@ -61,9 +58,7 @@ class CatalogZoneMemberIdentifier(models.Model): return self.name -class SeenTransferClients(models.Model): - _netbox_private = True - +class SeenTransferClient(NetBoxModel): source_ip = models.GenericIPAddressField() last_seen = models.DateTimeField() view = models.ForeignKey( @@ -77,6 +72,8 @@ class SeenTransferClients(models.Model): class Meta: ordering = ("source_ip",) + verbose_name = "Seen Transfer Client" + verbose_name_plural = "Seen Transfer Clients" constraints = [ models.UniqueConstraint( fields=["source_ip", "view"], diff --git a/netbox_dns_bridge/navigation.py b/netbox_dns_bridge/navigation.py index f8b616c..80a0671 100644 --- a/netbox_dns_bridge/navigation.py +++ b/netbox_dns_bridge/navigation.py @@ -2,32 +2,33 @@ from django.utils.translation import gettext_lazy as _ from netbox.plugins import PluginMenu, PluginMenuItem catalog_zones_menu_item = PluginMenuItem( - link="plugins:netbox_dns_bridge:catalog_zones", + link="plugins:netbox_dns_bridge:catalogzone_list", link_text=_("Catalog Zones"), - permissions=[], - staff_only=True, + permissions=["netbox_dns_bridge.view_catalogzone"], ) 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"), - permissions=[], - staff_only=True, + permissions=["netbox_dns_bridge.view_catalogzonemember"], ) -notify_menu_item = PluginMenuItem( - link="plugins:netbox_dns_bridge:notify", - link_text=_("Notify"), - permissions=[], - staff_only=True, +seen_transfer_clients_menu_item = PluginMenuItem( + link="plugins:netbox_dns_bridge:seentransferclient_list", + link_text=_("Clients"), + permissions=["netbox_dns_bridge.view_seentransferclient"], ) menu = PluginMenu( label=_("DNS Bridge"), groups=( ( - _("Information"), - (catalog_zones_menu_item, catalog_zone_members_menu_item, notify_menu_item), + _("Catalog Zone"), + (catalog_zones_menu_item, catalog_zone_members_menu_item), + ), + ( + _("Notify"), + (seen_transfer_clients_menu_item,), ), ), icon_class="mdi mdi-bridge", diff --git a/netbox_dns_bridge/signals/netbox_dns_zones.py b/netbox_dns_bridge/signals/netbox_dns_zones.py index f335254..b2ff8d9 100644 --- a/netbox_dns_bridge/signals/netbox_dns_zones.py +++ b/netbox_dns_bridge/signals/netbox_dns_zones.py @@ -77,15 +77,15 @@ def zone_post_save(sender, instance, created, **kwargs): transaction.on_commit(_on_commit) if created: - # If zone was created, create catz member identifier record for this zone. - catzm.update_member_identifier(zone) + # If zone was created, create the catalog zone member record for this zone. + catzm.update_member(zone) else: - # Re-create the catz member identifier when the zone name or its view - # changed. A view move leaves the identifier pointing at the wrong + # Re-create the catalog zone member when the zone name or its view + # changed. A view move leaves the member pointing at the wrong # catalog zone, so it must be refreshed. old_name = getattr(zone, "_old_name", None) 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) diff --git a/netbox_dns_bridge/tables.py b/netbox_dns_bridge/tables.py index 61bbb8d..8ee2c36 100644 --- a/netbox_dns_bridge/tables.py +++ b/netbox_dns_bridge/tables.py @@ -1,29 +1,30 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -import netbox_dns.models from netbox.tables import NetBoxTable +from netbox.tables.columns import ActionsColumn -from .models import CatalogZone +from .models import CatalogZone, CatalogZoneMember, SeenTransferClient class CatalogZoneMemberTable(NetBoxTable): name = tables.Column(linkify=True) + zone = tables.Column( + accessor="zone__name", + verbose_name=_("Zone"), + linkify=True, + ) view = tables.Column( - accessor="view__name", + accessor="catalog_zone__view__name", verbose_name=_("View"), ) - status = tables.Column(verbose_name=_("Status")) - catz_identifier = tables.Column( - accessor="catz_identifier__name", - verbose_name=_("Catalog Zone Identifier"), - default="—", - ) + + actions = ActionsColumn(actions=()) class Meta(NetBoxTable.Meta): - model = netbox_dns.models.Zone - fields = ("name", "view", "status", "catz_identifier") - default_columns = ("name", "view", "status", "catz_identifier") + model = CatalogZoneMember + fields = ("name", "zone", "view") + default_columns = ("name", "zone", "view") class CatalogZoneTable(NetBoxTable): @@ -38,6 +39,8 @@ class CatalogZoneTable(NetBoxTable): soa_expire = tables.Column(verbose_name=_("Expire")) soa_minimum = tables.Column(verbose_name=_("Minimum")) + actions = ActionsColumn(actions=("edit", "changelog")) + class Meta(NetBoxTable.Meta): model = CatalogZone fields = ( @@ -56,3 +59,20 @@ class CatalogZoneTable(NetBoxTable): "soa_expire", "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") diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html b/netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html deleted file mode 100644 index 1352a41..0000000 --- a/netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load i18n %} - -{% block title %}{% trans "Catalog Zone Members" %}{% endblock %} diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html b/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html new file mode 100644 index 0000000..dc11ad4 --- /dev/null +++ b/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html @@ -0,0 +1,50 @@ +{% extends 'generic/object.html' %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Catalog Zone SOA" %}
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "View" %}{{ object.view|linkify }}
{% trans "SOA Serial" %}{{ object.soa_serial }}
{% trans "Refresh" %}{{ object.soa_refresh }}
{% trans "Retry" %}{{ object.soa_retry }}
{% trans "Expire" %}{{ object.soa_expire }}
{% trans "Minimum" %}{{ object.soa_minimum }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html b/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html new file mode 100644 index 0000000..096d280 --- /dev/null +++ b/netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html @@ -0,0 +1,42 @@ +{% extends 'generic/object.html' %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Catalog Zone Member" %}
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Zone" %}{{ object.zone|linkify }}
{% trans "Catalog Zone" %}{{ object.catalog_zone|linkify }}
{% trans "View" %}{{ object.catalog_zone.view|linkify }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html b/netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html deleted file mode 100644 index 78c6a44..0000000 --- a/netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'base/layout.html' %} - -{% block title %}DNS Bridge Notify{% endblock %} - -{% block content %} -
-
- -
-
Seen Transfer Clients
-
- - - - - - - - - - {% for c in seen_clients %} - - - - - - {% empty %} - - {% endfor %} - -
Source IPViewLast Seen
{{ c.source_ip }}{{ c.view.name }}{{ c.last_seen|date:"Y-m-d H:i:s" }}
No seen transfer clients.
-
-
- -
-
-{% endblock %} diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html b/netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html new file mode 100644 index 0000000..ffb34d2 --- /dev/null +++ b/netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html @@ -0,0 +1,38 @@ +{% extends 'generic/object.html' %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Seen Transfer Client" %}
+ + + + + + + + + + + + + +
{% trans "Source IP" %}{{ object.source_ip }}
{% trans "View" %}{{ object.view|linkify }}
{% trans "Last Seen" %}{{ object.last_seen|date:"Y-m-d H:i:s" }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox_dns_bridge/tests/conftest.py b/netbox_dns_bridge/tests/conftest.py index 9a39229..14de5e8 100644 --- a/netbox_dns_bridge/tests/conftest.py +++ b/netbox_dns_bridge/tests/conftest.py @@ -182,7 +182,7 @@ def catz_serial(db): """Initial SOA serial value (1) for freshly created catalog zones. A ``CatalogZone`` is auto-created with ``soa_serial=1`` by the zone - post_save signal (via ``update_member_identifier``) whenever a zone is + post_save signal (via ``update_member``) whenever a zone is created. Tests compare against this initial value. """ return 1 diff --git a/netbox_dns_bridge/tests/test_catalog_zone_manager.py b/netbox_dns_bridge/tests/test_catalog_zone_manager.py index 2fa92fe..d2e3b14 100644 --- a/netbox_dns_bridge/tests/test_catalog_zone_manager.py +++ b/netbox_dns_bridge/tests/test_catalog_zone_manager.py @@ -9,7 +9,7 @@ from netbox_dns_bridge.management.commands.dns_transfer_endpoint import ( ) from netbox_dns_bridge.models import ( CatalogZone, - CatalogZoneMemberIdentifier, + CatalogZoneMember, ) @@ -72,50 +72,50 @@ class TestIncrementSoaSerial: @pytest.mark.django_db -class TestGenerateMemberIdentifier: +class TestGenerateMemberName: def test_returns_26_char_string(self): - name = catzm._generate_member_identifier() + name = catzm._generate_member_name() assert len(name) == 26 def test_is_lowercase(self): - name = catzm._generate_member_identifier() + name = catzm._generate_member_name() assert name == name.lower() def test_is_base32(self): import string - name = catzm._generate_member_identifier() + name = catzm._generate_member_name() valid_chars = set(string.ascii_lowercase + "234567") assert set(name).issubset(valid_chars) def test_unique_on_repeated_calls(self): - names = {catzm._generate_member_identifier() for _ in range(100)} + names = {catzm._generate_member_name() for _ in range(100)} assert len(names) == 100 @pytest.mark.django_db -class TestUpdateMemberIdentifier: +class TestUpdateMember: def test_creates_identifier_for_new_zone(self, make_zone): zone = make_zone(name="ident-new.test") # Signal already creates one; delete it to test creation - CatalogZoneMemberIdentifier.objects.filter(zone=zone).delete() - assert not CatalogZoneMemberIdentifier.objects.filter(zone=zone).exists() - catzm.update_member_identifier(zone) - assert CatalogZoneMemberIdentifier.objects.filter(zone=zone).exists() + CatalogZoneMember.objects.filter(zone=zone).delete() + assert not CatalogZoneMember.objects.filter(zone=zone).exists() + catzm.update_member(zone) + assert CatalogZoneMember.objects.filter(zone=zone).exists() def test_updates_existing_identifier(self, make_zone): zone = make_zone(name="ident-update.test") - catzm.update_member_identifier(zone) - old = CatalogZoneMemberIdentifier.objects.get(zone=zone) - catzm.update_member_identifier(zone) - new = CatalogZoneMemberIdentifier.objects.get(zone=zone) + catzm.update_member(zone) + old = CatalogZoneMember.objects.get(zone=zone) + catzm.update_member(zone) + new = CatalogZoneMember.objects.get(zone=zone) assert old.name != new.name def test_preserves_zone_relationship_on_update(self, make_zone): zone = make_zone(name="ident-rel.test") - catzm.update_member_identifier(zone) - catzm.update_member_identifier(zone) - assert CatalogZoneMemberIdentifier.objects.filter(zone=zone).count() == 1 + catzm.update_member(zone) + catzm.update_member(zone) + assert CatalogZoneMember.objects.filter(zone=zone).count() == 1 @pytest.mark.django_db @@ -195,11 +195,11 @@ class TestCreateZone: view = make_view(name="cz-ident-view") zone_obj = make_zone(name="no-ident.test", view=view) # Remove the auto-created identifier to test on-the-fly creation - CatalogZoneMemberIdentifier.objects.filter(zone=zone_obj).delete() - assert not CatalogZoneMemberIdentifier.objects.filter(zone=zone_obj).exists() + CatalogZoneMember.objects.filter(zone=zone_obj).delete() + assert not CatalogZoneMember.objects.filter(zone=zone_obj).exists() zone = catzm.create_zone("catz", "cz-ident-view") assert zone is not None - assert CatalogZoneMemberIdentifier.objects.filter(zone=zone_obj).exists() + assert CatalogZoneMember.objects.filter(zone=zone_obj).exists() def test_rdclass_is_in(self, make_zone, make_view): view = make_view(name="cz-class-view") diff --git a/netbox_dns_bridge/tests/test_models.py b/netbox_dns_bridge/tests/test_models.py index ba717c7..830ff47 100644 --- a/netbox_dns_bridge/tests/test_models.py +++ b/netbox_dns_bridge/tests/test_models.py @@ -3,8 +3,8 @@ import pytest from netbox_dns_bridge.models import ( CatalogZone, - CatalogZoneMemberIdentifier, - SeenTransferClients, + CatalogZoneMember, + SeenTransferClient, ) @@ -42,6 +42,15 @@ class TestCatalogZone: assert cz.soa_expire == 86400 assert cz.soa_minimum == 3600 + def test_is_full_netbox_model(self, make_view): + view = make_view(name="cz-netbox-model-view") + cz = CatalogZone.objects.create(view=view) + assert cz.created is not None + assert cz.last_updated is not None + assert cz.custom_field_data == {} + assert hasattr(cz, "tags") + assert cz.get_absolute_url() == f"/plugins/dns-bridge/catalog-zones/{cz.pk}/" + def test_one_to_one_relationship(self, make_view): view = make_view(name="cz-oto-view") cz = CatalogZone.objects.create(view=view, soa_serial=7) @@ -67,10 +76,10 @@ class TestCatalogZone: @pytest.mark.django_db -class TestCatalogZoneMemberIdentifier: +class TestCatalogZoneMember: def test_str_representation(self, make_zone): zone = make_zone(name="str.test") - m = CatalogZoneMemberIdentifier.objects.get(zone=zone) + m = CatalogZoneMember.objects.get(zone=zone) assert str(m) == m.name def test_unique_name_per_catalog_zone(self, make_zone, make_view): @@ -78,11 +87,11 @@ class TestCatalogZoneMemberIdentifier: zone1 = make_zone(name="uniq1.test", view=view) zone2 = make_zone(name="uniq2.test", view=view) - ident1 = CatalogZoneMemberIdentifier.objects.get(zone=zone1) + ident1 = CatalogZoneMember.objects.get(zone=zone1) ident1.name = "shared-name" ident1.save() - ident2 = CatalogZoneMemberIdentifier.objects.get(zone=zone2) + ident2 = CatalogZoneMember.objects.get(zone=zone2) from django.db import IntegrityError with pytest.raises(IntegrityError): @@ -95,36 +104,36 @@ class TestCatalogZoneMemberIdentifier: zone1 = make_zone(name="diff1.test", view=view1) zone2 = make_zone(name="diff2.test", view=view2) - ident1 = CatalogZoneMemberIdentifier.objects.get(zone=zone1) + ident1 = CatalogZoneMember.objects.get(zone=zone1) ident1.name = "shared-name" ident1.save() - ident2 = CatalogZoneMemberIdentifier.objects.get(zone=zone2) + ident2 = CatalogZoneMember.objects.get(zone=zone2) ident2.name = "shared-name" ident2.save() def test_cascade_delete_when_zone_deleted(self, make_zone): zone = make_zone(name="cascade.test") - m = CatalogZoneMemberIdentifier.objects.get(zone=zone) - assert CatalogZoneMemberIdentifier.objects.filter(pk=m.pk).exists() + m = CatalogZoneMember.objects.get(zone=zone) + assert CatalogZoneMember.objects.filter(pk=m.pk).exists() zone.delete() - assert not CatalogZoneMemberIdentifier.objects.filter(pk=m.pk).exists() + assert not CatalogZoneMember.objects.filter(pk=m.pk).exists() def test_one_to_one_relationship(self, make_zone): zone = make_zone(name="oto.test") - m = CatalogZoneMemberIdentifier.objects.get(zone=zone) - assert zone.catz_identifier == m + m = CatalogZoneMember.objects.get(zone=zone) + assert zone.catalog_zone_member == m def test_ordering_by_name(self, make_zone, make_view): view = make_view(name="ord-view") z1 = make_zone(name="z1.test", view=view) z2 = make_zone(name="z2.test", view=view) z3 = make_zone(name="z3.test", view=view) - CatalogZoneMemberIdentifier.objects.filter(zone=z3).update(name="gamma") - CatalogZoneMemberIdentifier.objects.filter(zone=z1).update(name="alpha") - CatalogZoneMemberIdentifier.objects.filter(zone=z2).update(name="beta") + CatalogZoneMember.objects.filter(zone=z3).update(name="gamma") + CatalogZoneMember.objects.filter(zone=z1).update(name="alpha") + CatalogZoneMember.objects.filter(zone=z2).update(name="beta") names = list( - CatalogZoneMemberIdentifier.objects.filter( + CatalogZoneMember.objects.filter( zone__in=[z1, z2, z3] ).values_list("name", flat=True) ) @@ -132,10 +141,10 @@ class TestCatalogZoneMemberIdentifier: @pytest.mark.django_db -class TestSeenTransferClients: +class TestSeenTransferClient: def test_str_representation(self, make_view): view = make_view(name="str-view") - client = SeenTransferClients.objects.create( + client = SeenTransferClient.objects.create( source_ip="192.0.2.1", view=view, last_seen="2026-01-01T00:00:00Z", @@ -146,13 +155,13 @@ class TestSeenTransferClients: from django.db import IntegrityError view = make_view(name="uniq-view") - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="10.0.0.1", view=view, last_seen="2026-01-01T00:00:00Z", ) with pytest.raises(IntegrityError): - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="10.0.0.1", view=view, last_seen="2026-01-02T00:00:00Z", @@ -161,19 +170,19 @@ class TestSeenTransferClients: def test_same_ip_different_views_allowed(self, make_view): v1 = make_view(name="v1") v2 = make_view(name="v2") - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="10.0.0.5", view=v1, last_seen="2026-01-01T00:00:00Z" ) - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="10.0.0.5", view=v2, last_seen="2026-01-01T00:00:00Z" ) - assert SeenTransferClients.objects.filter(source_ip="10.0.0.5").count() == 2 + assert SeenTransferClient.objects.filter(source_ip="10.0.0.5").count() == 2 def test_protect_on_view_delete(self, make_view): from django.db.models import ProtectedError view = make_view(name="protected-view") - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="10.0.0.9", view=view, last_seen="2026-01-01T00:00:00Z", @@ -187,6 +196,19 @@ class TestSeenTransferClients: v = make_view(name="ord-view") now = timezone.now() for ip in ["10.0.0.3", "10.0.0.1", "10.0.0.2"]: - SeenTransferClients.objects.create(source_ip=ip, view=v, last_seen=now) - ips = list(SeenTransferClients.objects.values_list("source_ip", flat=True)) + SeenTransferClient.objects.create(source_ip=ip, view=v, last_seen=now) + ips = list(SeenTransferClient.objects.values_list("source_ip", flat=True)) assert ips == ["10.0.0.1", "10.0.0.2", "10.0.0.3"] + + def test_is_full_netbox_model(self, make_view): + from django.utils import timezone + + view = make_view(name="stc-netbox-model-view") + c = SeenTransferClient.objects.create( + source_ip="192.0.2.1", view=view, last_seen=timezone.now() + ) + assert c.created is not None + assert c.last_updated is not None + assert c.custom_field_data == {} + assert hasattr(c, "tags") + assert c.get_absolute_url() == f"/plugins/dns-bridge/seen-transfer-clients/{c.pk}/" diff --git a/netbox_dns_bridge/tests/test_notify.py b/netbox_dns_bridge/tests/test_notify.py index dc03a0e..ebad906 100644 --- a/netbox_dns_bridge/tests/test_notify.py +++ b/netbox_dns_bridge/tests/test_notify.py @@ -269,11 +269,11 @@ class TestScheduleCatalogZoneNotify: class TestSendClientNotifyRun: def test_sends_single_notify_to_seen_clients(self, make_zone, make_view, monkeypatch): import netbox_dns_bridge.jobs.notify as nh - from netbox_dns_bridge.models import SeenTransferClients + from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone view = make_view(name="notify-run-view") - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="192.0.2.99", view=view, last_seen=timezone.now() ) @@ -297,11 +297,11 @@ class TestSendClientNotifyRun: assert mock_udp.call_count == 1 def test_sends_notify_without_catalog_zone(self, monkeypatch, make_view): - from netbox_dns_bridge.models import SeenTransferClients + from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone view = make_view(name="no-catz-view") - SeenTransferClients.objects.create( + SeenTransferClient.objects.create( source_ip="192.0.2.1", view=view, last_seen=timezone.now() ) diff --git a/netbox_dns_bridge/tests/test_request_handler_integration.py b/netbox_dns_bridge/tests/test_request_handler_integration.py index c5c317f..2fa4f0e 100644 --- a/netbox_dns_bridge/tests/test_request_handler_integration.py +++ b/netbox_dns_bridge/tests/test_request_handler_integration.py @@ -148,14 +148,14 @@ class TestCatzIntegration: class TestSeenClientTracking: def _wait_for_seen_client(self, view, timeout=3.0): - """Poll for SeenTransferClients record (race: response is sent before + """Poll for SeenTransferClient record (race: response is sent before _track_seen_client completes in the handler thread).""" import time - from netbox_dns_bridge.models import SeenTransferClients + from netbox_dns_bridge.models import SeenTransferClient deadline = time.monotonic() + timeout while time.monotonic() < deadline: - if SeenTransferClients.objects.filter( + if SeenTransferClient.objects.filter( source_ip="127.0.0.1", view=view ).exists(): return True @@ -186,5 +186,5 @@ class TestSeenClientTracking: import time time.sleep(0.3) - from netbox_dns_bridge.models import SeenTransferClients - assert not SeenTransferClients.objects.filter(view=view).exists() + from netbox_dns_bridge.models import SeenTransferClient + assert not SeenTransferClient.objects.filter(view=view).exists() diff --git a/netbox_dns_bridge/tests/test_signals.py b/netbox_dns_bridge/tests/test_signals.py index 02e3e0e..9348d5a 100644 --- a/netbox_dns_bridge/tests/test_signals.py +++ b/netbox_dns_bridge/tests/test_signals.py @@ -13,7 +13,7 @@ captureOnCommitCallbacks = _DjangoTestCase.captureOnCommitCallbacks from netbox_dns_bridge.models import ( CatalogZone, - CatalogZoneMemberIdentifier, + CatalogZoneMember, ) @@ -35,9 +35,9 @@ class TestZonePreSave: @pytest.mark.django_db class TestZonePostSaveCreate: - def test_creates_catz_member_identifier_on_new_zone(self, make_zone): + def test_creates_catalog_zone_member_on_new_zone(self, make_zone): zone = make_zone(name="postcreate.test") - assert CatalogZoneMemberIdentifier.objects.filter(zone=zone).exists() + assert CatalogZoneMember.objects.filter(zone=zone).exists() def test_increments_soa_serial_on_commit(self, make_zone, catz_serial): with captureOnCommitCallbacks(execute=True): @@ -103,19 +103,19 @@ class TestZonePostSaveCreate: @pytest.mark.django_db class TestZonePostSaveUpdate: - def test_updates_catz_identifier_on_name_change(self, make_zone): + def test_updates_member_on_name_change(self, make_zone): zone = make_zone(name="rename-old.test") - old_name = CatalogZoneMemberIdentifier.objects.get(zone=zone).name + old_name = CatalogZoneMember.objects.get(zone=zone).name zone.name = "rename-new.test" zone.save() - new_name = CatalogZoneMemberIdentifier.objects.get(zone=zone).name + new_name = CatalogZoneMember.objects.get(zone=zone).name assert old_name != new_name def test_no_identifier_update_when_name_unchanged(self, make_zone): zone = make_zone(name="nochange.test") - ident = CatalogZoneMemberIdentifier.objects.get(zone=zone) + ident = CatalogZoneMember.objects.get(zone=zone) original_name = ident.name zone.soa_serial = zone.soa_serial + 1 @@ -177,7 +177,7 @@ class TestZoneViewChange: view_b = make_view(name="fk-view-b") zone = make_zone(name="fk-move.test", view=view_a) - ident = CatalogZoneMemberIdentifier.objects.get(zone=zone) + ident = CatalogZoneMember.objects.get(zone=zone) assert ident.catalog_zone == CatalogZone.objects.get(view=view_a) zone.view = view_b diff --git a/netbox_dns_bridge/tests/test_smoke.py b/netbox_dns_bridge/tests/test_smoke.py index 4fdfe7b..d797a67 100644 --- a/netbox_dns_bridge/tests/test_smoke.py +++ b/netbox_dns_bridge/tests/test_smoke.py @@ -14,12 +14,12 @@ def test_plugin_importable(): def test_models_importable(): from netbox_dns_bridge.models import ( CatalogZone, - CatalogZoneMemberIdentifier, - SeenTransferClients, + CatalogZoneMember, + SeenTransferClient, ) assert CatalogZone._meta.model_name - assert CatalogZoneMemberIdentifier._meta.model_name - assert SeenTransferClients._meta.model_name + assert CatalogZoneMember._meta.model_name + assert SeenTransferClient._meta.model_name def test_request_handler_importable(): diff --git a/netbox_dns_bridge/tests/test_views.py b/netbox_dns_bridge/tests/test_views.py index 137f359..19a7951 100644 --- a/netbox_dns_bridge/tests/test_views.py +++ b/netbox_dns_bridge/tests/test_views.py @@ -1,4 +1,4 @@ -"""Tests for views: CatalogZoneMembersView, CatalogZoneListView, CatalogZoneEditView, NotifyOverviewView — auth gate and context.""" +"""Tests for views: CatalogZone list/detail/edit, CatalogZoneMember list/detail, SeenTransferClient list/detail — auth gate and context.""" import pytest from django.test import Client from django.contrib.auth import get_user_model @@ -28,7 +28,7 @@ def regular_user(db): @pytest.mark.django_db -class TestCatalogZoneMembersView: +class TestCatalogZoneMemberListView: def test_unauthenticated_redirects_to_login(self, client): resp = client.get("/plugins/dns-bridge/catalog-zone-members/") assert resp.status_code in (302, 403) @@ -38,11 +38,48 @@ class TestCatalogZoneMembersView: resp = client.get("/plugins/dns-bridge/catalog-zone-members/") assert resp.status_code in (302, 403) - def test_superuser_can_access(self, client, superuser): + def test_superuser_can_access(self, client, superuser, make_zone): + make_zone(name="member-list.test") client.login(username="admin", password="testpass") resp = client.get("/plugins/dns-bridge/catalog-zone-members/") assert resp.status_code == 200 + def test_list_contains_members(self, client, superuser, make_zone): + zone = make_zone(name="member-row.test") + client.login(username="admin", password="testpass") + resp = client.get("/plugins/dns-bridge/catalog-zone-members/") + member_list = list(resp.context["table"].data) + assert len(member_list) >= 1 + m = next(m for m in member_list if m.zone_id == zone.id) + assert m.name + + +@pytest.mark.django_db +class TestCatalogZoneMemberDetailView: + def test_unauthenticated_redirects_to_login(self, client, make_zone): + zone = make_zone(name="member-detail.test") + from netbox_dns_bridge.models import CatalogZoneMember + m = CatalogZoneMember.objects.get(zone=zone) + resp = client.get(f"/plugins/dns-bridge/catalog-zone-members/{m.pk}/") + assert resp.status_code in (302, 403) + + def test_regular_user_forbidden(self, client, regular_user, make_zone): + zone = make_zone(name="member-detail-forbidden.test") + from netbox_dns_bridge.models import CatalogZoneMember + m = CatalogZoneMember.objects.get(zone=zone) + client.login(username="regular", password="testpass") + resp = client.get(f"/plugins/dns-bridge/catalog-zone-members/{m.pk}/") + assert resp.status_code in (302, 403) + + def test_superuser_can_access_detail(self, client, superuser, make_zone): + zone = make_zone(name="member-detail-get.test") + from netbox_dns_bridge.models import CatalogZoneMember + m = CatalogZoneMember.objects.get(zone=zone) + client.login(username="admin", password="testpass") + resp = client.get(f"/plugins/dns-bridge/catalog-zone-members/{m.pk}/") + assert resp.status_code == 200 + assert resp.context["object"].name == m.name + @pytest.mark.django_db class TestCatalogZoneListView: @@ -138,45 +175,104 @@ class TestCatalogZoneEditView: @pytest.mark.django_db -class TestNotifyOverviewView: +class TestCatalogZoneDetailView: + def test_unauthenticated_redirects_to_login(self, client, make_view): + view = make_view(name="detail-cz-view") + from netbox_dns_bridge.models import CatalogZone + cz = CatalogZone.objects.create(view=view) + resp = client.get(f"/plugins/dns-bridge/catalog-zones/{cz.pk}/") + assert resp.status_code in (302, 403) + + def test_regular_user_forbidden(self, client, regular_user, make_view): + view = make_view(name="detail-cz-view-forbidden") + from netbox_dns_bridge.models import CatalogZone + cz = CatalogZone.objects.create(view=view) + client.login(username="regular", password="testpass") + resp = client.get(f"/plugins/dns-bridge/catalog-zones/{cz.pk}/") + assert resp.status_code in (302, 403) + + def test_superuser_can_access_detail(self, client, superuser, make_view): + view = make_view(name="detail-cz-view-get") + from netbox_dns_bridge.models import CatalogZone + cz = CatalogZone.objects.create(view=view, soa_serial=42) + client.login(username="admin", password="testpass") + resp = client.get(f"/plugins/dns-bridge/catalog-zones/{cz.pk}/") + assert resp.status_code == 200 + assert resp.context["object"].soa_serial == 42 + + +@pytest.mark.django_db +class TestSeenTransferClientListView: def test_unauthenticated_redirects_to_login(self, client): - resp = client.get("/plugins/dns-bridge/notify/") + resp = client.get("/plugins/dns-bridge/seen-transfer-clients/") assert resp.status_code in (302, 403) def test_regular_user_forbidden(self, client, regular_user): client.login(username="regular", password="testpass") - resp = client.get("/plugins/dns-bridge/notify/") + resp = client.get("/plugins/dns-bridge/seen-transfer-clients/") assert resp.status_code in (302, 403) - def test_superuser_can_access(self, client, superuser): - client.login(username="admin", password="testpass") - resp = client.get("/plugins/dns-bridge/notify/") - assert resp.status_code == 200 - - def test_context_contains_seen_clients(self, client, superuser, make_view): - from netbox_dns_bridge.models import SeenTransferClients + def test_superuser_can_access(self, client, superuser, make_view): + from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone - view = make_view(name="notify-view") - SeenTransferClients.objects.create( + view = make_view(name="stc-list-view") + SeenTransferClient.objects.create( source_ip="192.0.2.1", view=view, last_seen=timezone.now() ) client.login(username="admin", password="testpass") - resp = client.get("/plugins/dns-bridge/notify/") - assert "seen_clients" in resp.context - clients = list(resp.context["seen_clients"]) - assert len(clients) == 1 - assert clients[0].source_ip == "192.0.2.1" + resp = client.get("/plugins/dns-bridge/seen-transfer-clients/") + assert resp.status_code == 200 - def test_seen_clients_ordered_by_ip(self, client, superuser, make_view): - from netbox_dns_bridge.models import SeenTransferClients + def test_list_contains_clients(self, client, superuser, make_view): + from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone - view = make_view(name="ord-notify-view") - now = timezone.now() - for ip in ["10.0.0.3", "10.0.0.1", "10.0.0.2"]: - SeenTransferClients.objects.create(source_ip=ip, view=view, last_seen=now) + view = make_view(name="stc-list-row-view") + SeenTransferClient.objects.create( + source_ip="192.0.2.1", view=view, last_seen=timezone.now() + ) client.login(username="admin", password="testpass") - resp = client.get("/plugins/dns-bridge/notify/") - ips = [c.source_ip for c in resp.context["seen_clients"]] - assert ips == ["10.0.0.1", "10.0.0.2", "10.0.0.3"] + resp = client.get("/plugins/dns-bridge/seen-transfer-clients/") + client_list = list(resp.context["table"].data) + assert len(client_list) >= 1 + assert client_list[0].source_ip == "192.0.2.1" + + +@pytest.mark.django_db +class TestSeenTransferClientDetailView: + def test_unauthenticated_redirects_to_login(self, client, make_view): + from netbox_dns_bridge.models import SeenTransferClient + from django.utils import timezone + + view = make_view(name="stc-detail-view") + c = SeenTransferClient.objects.create( + source_ip="192.0.2.1", view=view, last_seen=timezone.now() + ) + resp = client.get(f"/plugins/dns-bridge/seen-transfer-clients/{c.pk}/") + assert resp.status_code in (302, 403) + + def test_regular_user_forbidden(self, client, regular_user, make_view): + from netbox_dns_bridge.models import SeenTransferClient + from django.utils import timezone + + view = make_view(name="stc-detail-forbidden-view") + c = SeenTransferClient.objects.create( + source_ip="192.0.2.1", view=view, last_seen=timezone.now() + ) + client.login(username="regular", password="testpass") + resp = client.get(f"/plugins/dns-bridge/seen-transfer-clients/{c.pk}/") + assert resp.status_code in (302, 403) + + def test_superuser_can_access_detail(self, client, superuser, make_view): + from netbox_dns_bridge.models import SeenTransferClient + from django.utils import timezone + + view = make_view(name="stc-detail-get-view") + c = SeenTransferClient.objects.create( + source_ip="192.0.2.42", view=view, last_seen=timezone.now() + ) + client.login(username="admin", password="testpass") + resp = client.get(f"/plugins/dns-bridge/seen-transfer-clients/{c.pk}/") + assert resp.status_code == 200 + assert resp.context["object"].source_ip == "192.0.2.42" diff --git a/netbox_dns_bridge/urls.py b/netbox_dns_bridge/urls.py index 18d97e2..441a70a 100644 --- a/netbox_dns_bridge/urls.py +++ b/netbox_dns_bridge/urls.py @@ -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, CatalogZoneListView, - CatalogZoneMembersView, - NotifyOverviewView, + CatalogZoneMemberListView, + CatalogZoneMemberView, + CatalogZoneView, + SeenTransferClientListView, + SeenTransferClientView, ) urlpatterns = [ - path("catalog-zones/", CatalogZoneListView.as_view(), name="catalog_zones"), path( - "catalog-zones//edit/", - CatalogZoneEditView.as_view(), - name="catalogzone_edit", + "catalog-zones/", + include( + get_model_urls("netbox_dns_bridge", "catalogzone", detail=False) + ), + ), + path( + "catalog-zones//", + include(get_model_urls("netbox_dns_bridge", "catalogzone")), ), path( "catalog-zone-members/", - CatalogZoneMembersView.as_view(), - name="catalog_zone_members", + include( + get_model_urls("netbox_dns_bridge", "catalogzonemember", detail=False) + ), + ), + path( + "catalog-zone-members//", + 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//", + include(get_model_urls("netbox_dns_bridge", "seentransferclient")), ), - path("notify/", NotifyOverviewView.as_view(), name="notify"), ] diff --git a/netbox_dns_bridge/views.py b/netbox_dns_bridge/views.py index 1b310f8..1463b9d 100644 --- a/netbox_dns_bridge/views.py +++ b/netbox_dns_bridge/views.py @@ -1,67 +1,76 @@ -from django.contrib.auth.decorators import user_passes_test -from django.shortcuts import render -from django.utils.decorators import method_decorator -from django.views import View +from netbox.object_actions import EditObject +from netbox.views import generic +from utilities.views import register_model_view -import netbox_dns.models -from netbox.views.generic import ObjectEditView, ObjectListView - -from .filtersets import CatalogZoneFilterSet, ZoneFilterSet +from .filtersets import ( + CatalogZoneFilterSet, + CatalogZoneMemberFilterSet, + SeenTransferClientFilterSet, +) from .forms import ( CatalogZoneFilterForm, CatalogZoneForm, 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( - user_passes_test(lambda u: u.is_superuser), name="dispatch" -) -class CatalogZoneListView(ObjectListView): +@register_model_view(CatalogZone, "list", path="", detail=False) +class CatalogZoneListView(generic.ObjectListView): queryset = CatalogZone.objects.select_related("view").order_by("view__name") table = CatalogZoneTable filterset = CatalogZoneFilterSet 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 = () -@method_decorator( - user_passes_test(lambda u: u.is_superuser), name="dispatch" -) -class NotifyOverviewView(View): - template_name = "netbox_dns_bridge/notify_overview.html" +@register_model_view(CatalogZone) +class CatalogZoneView(generic.ObjectView): + queryset = CatalogZone.objects.select_related("view") + actions = (EditObject,) - def get(self, request): - seen_clients = ( - SeenTransferClients.objects.select_related("view") - .all() - .order_by("source_ip") - ) - context = {"seen_clients": seen_clients} - return render(request, self.template_name, context) + +@register_model_view(CatalogZone, "edit") +class CatalogZoneEditView(generic.ObjectEditView): + queryset = CatalogZone.objects.select_related("view") + form = CatalogZoneForm + + +@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 = ()