diff --git a/netbox_dns/api/serializers_/record.py b/netbox_dns/api/serializers_/record.py index 6e79e872..c0174c72 100644 --- a/netbox_dns/api/serializers_/record.py +++ b/netbox_dns/api/serializers_/record.py @@ -41,6 +41,8 @@ class Meta: "tenant", "ipam_ip_address", "absolute_value", + "expiration_date", + "expired", ) brief_fields = ( @@ -57,6 +59,7 @@ class Meta: "description", "managed", "active", + "expired", ) url = serializers.HyperlinkedIdentityField( @@ -102,3 +105,7 @@ class Meta: required=False, allow_null=True, ) + expired = serializers.SerializerMethodField() + + def get_expired(self, instance): + return instance.is_expired diff --git a/netbox_dns/filtersets/record.py b/netbox_dns/filtersets/record.py index 70da74ba..c3f82d10 100755 --- a/netbox_dns/filtersets/record.py +++ b/netbox_dns/filtersets/record.py @@ -1,6 +1,7 @@ import django_filters import netaddr from django.db.models import Q +from django.utils.timezone import datetime from ipam.models import IPAddress from netbox.filtersets import PrimaryModelFilterSet @@ -73,6 +74,10 @@ class Meta: method="filter_ip_address", ) active = django_filters.BooleanFilter() + expiration_date = django_filters.DateFromToRangeFilter() + expired = django_filters.BooleanFilter( + method="filter_expired", + ) def filter_ip_address(self, queryset, name, value): if not value: @@ -88,6 +93,12 @@ def filter_ip_address(self, queryset, name, value): except (netaddr.AddrFormatError, ValueError): return queryset.none() + def filter_expired(self, queryset, name, value): + if value: + return queryset.filter(expiration_date__lt=datetime.now()) + + return queryset.exclude(expiration_date__lt=datetime.now()) + def search(self, queryset, name, value): if not value.strip(): return queryset diff --git a/netbox_dns/forms/record.py b/netbox_dns/forms/record.py index 1e06ec36..54b9cec9 100755 --- a/netbox_dns/forms/record.py +++ b/netbox_dns/forms/record.py @@ -22,7 +22,7 @@ TagFilterField, ) from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect +from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker __all__ = ( "RecordForm", @@ -46,6 +46,7 @@ class Meta: "value", "status", "ttl", + "expiration_date", "disable_ptr", "tenant_group", "tenant", @@ -57,6 +58,10 @@ class Meta: "ttl": _("TTL"), } + widgets = { + "expiration_date": DatePicker, + } + fieldsets = ( FieldSet( "name", @@ -68,6 +73,7 @@ class Meta: "status", "ttl", "disable_ptr", + "expiration_date", name=_("Record"), ), FieldSet( @@ -141,6 +147,8 @@ class RecordFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): "ttl", "disable_ptr", "active", + "expiration_date_before", + "expiration_date_after", name=_("Attributes"), ), FieldSet( @@ -176,6 +184,16 @@ class RecordFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), label=_("Disable PTR"), ) + expiration_date_before = forms.DateField( + required=False, + label=_("Expiration Date Before"), + widget=DatePicker, + ) + expiration_date_after = forms.DateField( + required=False, + label=_("Expiration Date After"), + widget=DatePicker, + ) status = forms.MultipleChoiceField( choices=RecordStatusChoices, required=False, @@ -221,6 +239,7 @@ class Meta: "value", "ttl", "disable_ptr", + "expiration_date", "tenant", "tags", ) @@ -302,6 +321,7 @@ class RecordBulkEditForm(PrimaryModelBulkEditForm): "status", "ttl", "disable_ptr", + "expiration_date", name=_("Attributes"), ), FieldSet( @@ -315,6 +335,7 @@ class RecordBulkEditForm(PrimaryModelBulkEditForm): "description", "ttl", "tenant", + "expiration_date", ) zone = DynamicModelChoiceField( @@ -355,3 +376,8 @@ class RecordBulkEditForm(PrimaryModelBulkEditForm): required=False, label=_("Tenant"), ) + expiration_date = forms.DateField( + required=False, + label=_("Expiration Date"), + widget=DatePicker, + ) diff --git a/netbox_dns/graphql/filters/record.py b/netbox_dns/graphql/filters/record.py index 5c6ce862..5564289f 100644 --- a/netbox_dns/graphql/filters/record.py +++ b/netbox_dns/graphql/filters/record.py @@ -1,9 +1,10 @@ +from datetime import date from typing import TYPE_CHECKING, Annotated import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import DateFilterLookup, FilterLookup try: from strawberry_django import StrFilterLookup @@ -58,6 +59,7 @@ class NetBoxDNSRecordFilter( | None ) = strawberry_django.filter_field() value: StrFilterLookup[str] | None = strawberry_django.filter_field() + expiration_date: DateFilterLookup[date] | None = strawberry_django.filter_field() disable_ptr: FilterLookup[bool] | None = strawberry_django.filter_field() status: ( Annotated[ diff --git a/netbox_dns/migrations/0032_record_expiration_date.py b/netbox_dns/migrations/0032_record_expiration_date.py new file mode 100644 index 00000000..ebe5f073 --- /dev/null +++ b/netbox_dns/migrations/0032_record_expiration_date.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-06-07 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_dns", "0031_record_netbox_dns_record_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="record", + name="expiration_date", + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/netbox_dns/models/record.py b/netbox_dns/models/record.py index 0140f13b..9fe21871 100644 --- a/netbox_dns/models/record.py +++ b/netbox_dns/models/record.py @@ -1,4 +1,5 @@ import ipaddress +from datetime import date import dns import netaddr @@ -144,6 +145,7 @@ class Meta: "disable_ptr", "description", "tenant", + "expiration_date", ) def __init__(self, *args, **kwargs): @@ -258,6 +260,11 @@ def __str__(self): null=True, blank=True, ) + expiration_date = models.DateField( + verbose_name=_("Expiration Date"), + blank=True, + null=True, + ) @property def cleanup_ptr_record(self): @@ -307,6 +314,13 @@ def is_active(self): and self.zone.status in ZONE_ACTIVE_STATUS_LIST ) + @property + def is_expired(self): + if self.expiration_date is None: + return False + + return self.expiration_date < date.today() + @property def is_address_record(self): return self.type in (RecordTypeChoices.A, RecordTypeChoices.AAAA) diff --git a/netbox_dns/tables/record.py b/netbox_dns/tables/record.py index ea067043..dc4d3e4a 100644 --- a/netbox_dns/tables/record.py +++ b/netbox_dns/tables/record.py @@ -73,6 +73,7 @@ class Meta(PrimaryModelTable.Meta): fields = ( "status", "description", + "expiration_date", ) default_columns = ( diff --git a/netbox_dns/templates/netbox_dns/record.html b/netbox_dns/templates/netbox_dns/record.html index 055e0765..944cfa67 100644 --- a/netbox_dns/templates/netbox_dns/record.html +++ b/netbox_dns/templates/netbox_dns/record.html @@ -39,11 +39,11 @@ {% block content %}
{% if object.managed %} -
+
{% else %} -
+
{% endif %} -
+
{% trans "Record" %}
@@ -117,6 +117,18 @@
{% trans "Record" %}
{% endif %} + {% if object.expiration_date %} + + + + + {% if expiration_warning %} + + + + + {% endif %} + {% endif %} {% if object.ptr_record %} @@ -149,52 +161,52 @@
{% trans "Record" %}
{% checkmark object.disable_ptr %}
{% trans "Expiration Date" %}{{ object.expiration_date|isodate }}
{% trans "Warning" %}{{ expiration_warning }}
{% trans "PTR Record" %}
- {% if rrset_record_table %} -
- {% if rrset_record_table.rows|length == 1 %} -
{% trans "Other Record in the RRSET" %}
- {% else %} -
{% trans "Other Records in the RRSET" %}
- {% endif %} -
- {% render_table rrset_record_table 'inc/table.html' %} -
+ {% if rrset_record_table %} +
+ {% if rrset_record_table.rows|length == 1 %} +
{% trans "Other Record in the RRSET" %}
+ {% else %} +
{% trans "Other Records in the RRSET" %}
+ {% endif %} +
+ {% render_table rrset_record_table 'inc/table.html' %}
- {% endif %} - {% if cname_target_table %} -
- {% if cname_target_table.rows|length == 1 %} -

{% trans "CNAME Target" %}

- {% else %} -

{% trans "CNAME Targets" %}

- {% endif %} -
- {% render_table cname_target_table 'inc/table.html' %} -
+
+ {% endif %} + {% if cname_target_table %} +
+ {% if cname_target_table.rows|length == 1 %} +

{% trans "CNAME Target" %}

+ {% else %} +

{% trans "CNAME Targets" %}

+ {% endif %} +
+ {% render_table cname_target_table 'inc/table.html' %}
- {% elif cname_table %} -
- {% if cname_table.rows|length == 1 %} -

{% trans "CNAME" %}

- {% else %} -

{% trans "CNAMEs" %}

- {% endif %} -
- {% render_table cname_table 'inc/table.html' %} -
+
+ {% elif cname_table %} +
+ {% if cname_table.rows|length == 1 %} +

{% trans "CNAME" %}

+ {% else %} +

{% trans "CNAMEs" %}

+ {% endif %} +
+ {% render_table cname_table 'inc/table.html' %}
- {% endif %} - {% if not object.managed %} +
+ {% endif %} + {% if not object.managed %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} - {% endif %} + {% endif %}
{% if not object.managed %} -
- {% include 'inc/panels/tags.html' %} -
- {% plugin_right_page object %} +
+ {% include 'inc/panels/tags.html' %} +
+ {% plugin_right_page object %} {% endif %}
diff --git a/netbox_dns/tests/record/test_api.py b/netbox_dns/tests/record/test_api.py index 8945ffc1..eed929c0 100644 --- a/netbox_dns/tests/record/test_api.py +++ b/netbox_dns/tests/record/test_api.py @@ -27,6 +27,7 @@ class RecordAPITestCase( "active", "description", "display", + "expired", "fqdn", "id", "managed", @@ -102,6 +103,7 @@ def setUpTestData(cls): name="example1", value="192.168.1.1", ttl=5000, + expiration_date="2026-06-30", ), Record( zone=cls.zones[4], @@ -109,6 +111,7 @@ def setUpTestData(cls): name="example2", value="fe80::dead:beef", ttl=6000, + expiration_date="2026-05-21", ), Record( zone=cls.zones[5], @@ -116,6 +119,7 @@ def setUpTestData(cls): name="example3", value="TXT Record", ttl=7000, + expiration_date="2066-01-07", ), Record( zone=cls.zones[6], @@ -276,3 +280,56 @@ def test_delete_managed_record(self): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + def test_record_expired(self): + record = Record.objects.create( + name="name1", + zone=self.zones[0], + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + expiration_date="2026-06-02", + ) + + url = reverse( + "plugins-api:netbox_dns-api:record-detail", kwargs={"pk": record.pk} + ) + self.add_permissions("netbox_dns.view_record") + + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertTrue(response.json().get("expired")) + + def test_record_expired_future_false(self): + record = Record.objects.create( + name="name1", + zone=self.zones[0], + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + expiration_date="2226-06-30", + ) + + url = reverse( + "plugins-api:netbox_dns-api:record-detail", kwargs={"pk": record.pk} + ) + self.add_permissions("netbox_dns.view_record") + + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertFalse(response.json().get("expired")) + + def test_record_expired_no_expiration_date_false(self): + record = Record.objects.create( + name="name1", + zone=self.zones[0], + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + ) + + url = reverse( + "plugins-api:netbox_dns-api:record-detail", kwargs={"pk": record.pk} + ) + self.add_permissions("netbox_dns.view_record") + + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertFalse(response.json().get("expired")) diff --git a/netbox_dns/tests/record/test_filtersets.py b/netbox_dns/tests/record/test_filtersets.py index bc8886f0..8de7da19 100644 --- a/netbox_dns/tests/record/test_filtersets.py +++ b/netbox_dns/tests/record/test_filtersets.py @@ -52,6 +52,7 @@ def setUpTestData(cls): type=RecordTypeChoices.AAAA, value="fe80::dead:beef", tenant=cls.tenants[0], + expiration_date="2026-05-21", ), Record( name="name2", @@ -60,6 +61,7 @@ def setUpTestData(cls): type=RecordTypeChoices.A, value="10.0.0.42", tenant=cls.tenants[0], + expiration_date="2026-05-21", ), Record( name="name3", @@ -69,6 +71,7 @@ def setUpTestData(cls): value="fe80::dead:beef", tenant=cls.tenants[1], managed=True, + expiration_date="2026-06-02", ), Record( name="name5", @@ -77,6 +80,7 @@ def setUpTestData(cls): type=RecordTypeChoices.TXT, value="Nothing to see here", status=RecordStatusChoices.STATUS_INACTIVE, + expiration_date="2166-01-07", ), Record( name="name1", @@ -201,6 +205,18 @@ def test_active(self): params = {"active": False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + def test_expiration_date(self): + params = {"expiration_date_before": "2026-06-30"} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {"expiration_date_after": "2026-06-30"} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_expired(self): + params = {"expired": True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {"expired": False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_ptr_record(self): Zone.objects.create(name="1.0.10.in-addr.arpa", **self.zone_data) address_record = Record.objects.create( diff --git a/netbox_dns/tests/record/test_views.py b/netbox_dns/tests/record/test_views.py index 8d685455..4b0057cd 100644 --- a/netbox_dns/tests/record/test_views.py +++ b/netbox_dns/tests/record/test_views.py @@ -1,3 +1,5 @@ +from datetime import date + from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -63,6 +65,7 @@ def setUpTestData(cls): name="name2", value="192.168.1.1", ttl=200, + expiration_date="2026-06-30", ), Record( zone=cls.zones[0], @@ -70,6 +73,7 @@ def setUpTestData(cls): name="name2", value="fe80:dead:beef::42", ttl=86400, + expiration_date="2026-06-02", ), Record( zone=cls.zones[4], @@ -77,6 +81,7 @@ def setUpTestData(cls): name="@", value="Test Text", ttl=86400, + expiration_date="2026-05-14", ), Record( zone=cls.zones[2], @@ -109,28 +114,29 @@ def setUpTestData(cls): "ttl": 86420, "disable_ptr": False, "description": "New Description", + "expiration_date": date(2026, 6, 30), } cls.csv_data = ( - "zone,view,type,name,value,ttl", - "zone1.example.com,,A,@,10.10.10.10,3600", - "zone2.example.com,,AAAA,name4,fe80::dead:beef,7200", - "zone1.example.com,,CNAME,dns,name1.zone2.example.com,100", - "zone2.example.com,,TXT,textname,textvalue,1000", - "zone1.example.com,view1,A,@,10.10.10.10,3600", - "zone2.example.com,view1,AAAA,name4,fe80::dead:beef,7200", - "zone1.example.com,view1,CNAME,dns,name1.zone2.example.com,100", - "zone2.example.com,view1,TXT,textname,textvalue,1000", - "zone1.example.com,view2,A,@,10.10.10.10,3600", - "zone2.example.com,view2,AAAA,name4,fe80::dead:beef,7200", - "zone1.example.com,view2,CNAME,dns,name1.zone2.example.com,100", - "zone2.example.com,view2,TXT,textname,textvalue,1000", + "zone,view,type,name,value,ttl,expiration_date", + "zone1.example.com,,A,@,10.10.10.10,3600,", + "zone2.example.com,,AAAA,name4,fe80::dead:beef,7200,", + "zone1.example.com,,CNAME,dns,name1.zone2.example.com,100,", + "zone2.example.com,,TXT,textname,textvalue,1000,", + "zone1.example.com,view1,A,@,10.10.10.10,3600,2026-06-30", + "zone2.example.com,view1,AAAA,name4,fe80::dead:beef,7200,2026-06-02", + "zone1.example.com,view1,CNAME,dns,name1.zone2.example.com,100,2026-05-14", + "zone2.example.com,view1,TXT,textname,textvalue,1000,", + "zone1.example.com,view2,A,@,10.10.10.10,3600,", + "zone2.example.com,view2,AAAA,name4,fe80::dead:beef,7200,", + "zone1.example.com,view2,CNAME,dns,name1.zone2.example.com,100,", + "zone2.example.com,view2,TXT,textname,textvalue,1000,", ) cls.csv_update_data = ( - "id,zone,type,value,ttl", - f"{cls.records[0].pk},{cls.zones[0].name},{RecordTypeChoices.A},10.0.1.1,86442", - f"{cls.records[1].pk},{cls.zones[1].name},{RecordTypeChoices.AAAA},fe80:dead:beef::23,86423", + "id,zone,type,value,ttl,expiration_date", + f"{cls.records[0].pk},{cls.zones[0].name},{RecordTypeChoices.A},10.0.1.1,86442,", + f"{cls.records[1].pk},{cls.zones[1].name},{RecordTypeChoices.AAAA},fe80:dead:beef::23,86423,2026-06-30", ) maxDiff = None @@ -453,3 +459,68 @@ def test_warning_mask_record_nonglue_aaaa_warn(self): response.content.decode(), "Record is masked by a child zone and may not be visible in DNS", ) + + def test_warning_record_expired(self): + self.add_permissions("netbox_dns.view_record") + + zone = Zone.objects.create(name="example.com", **self.zone_data) + + record = Record.objects.create( + name="name1", + zone=zone, + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + expiration_date="2026-06-02", + ) + + url = reverse("plugins:netbox_dns:record", kwargs={"pk": record.pk}) + + response = self.client.get(path=url) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertRegex( + response.content.decode(), + "Record is expired", + ) + + def test_warning_record_expired_future_ok(self): + self.add_permissions("netbox_dns.view_record") + + zone = Zone.objects.create(name="example.com", **self.zone_data) + + record = Record.objects.create( + name="name1", + zone=zone, + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + expiration_date="2226-06-02", + ) + + url = reverse("plugins:netbox_dns:record", kwargs={"pk": record.pk}) + + response = self.client.get(path=url) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertNotRegex( + response.content.decode(), + "Record is expired", + ) + + def test_warning_record_expired_no_expiration_ok(self): + self.add_permissions("netbox_dns.view_record") + + zone = Zone.objects.create(name="example.com", **self.zone_data) + + record = Record.objects.create( + name="name1", + zone=zone, + type=RecordTypeChoices.AAAA, + value="2001:db8::1", + ) + + url = reverse("plugins:netbox_dns:record", kwargs={"pk": record.pk}) + + response = self.client.get(path=url) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertNotRegex( + response.content.decode(), + "Record is expired", + ) diff --git a/netbox_dns/views/record.py b/netbox_dns/views/record.py index 92ba7509..0e597c6a 100644 --- a/netbox_dns/views/record.py +++ b/netbox_dns/views/record.py @@ -172,6 +172,9 @@ def get_extra_context(self, request, instance): context["rrset_record_table"] = rrset_record_table + if instance.expiration_date is not None and instance.is_expired: + context["expiration_warning"] = _("Record is expired") + if not instance.managed: try: instance.check_zone_cut_conflict()