From 83dc92ed2d760c186578ce54b017772e150d5c37 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 05:02:09 +0000 Subject: [PATCH 01/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 2c459a302..dcf1b4e6d 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-05 05:02+0000\n" +"POT-Creation-Date: 2024-06-07 05:01+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -10452,43 +10452,43 @@ msgstr "" msgid "Cannot delete stores from registry" msgstr "" -#: netbox/netbox/settings.py:722 +#: netbox/netbox/settings.py:724 msgid "German" msgstr "" -#: netbox/netbox/settings.py:723 +#: netbox/netbox/settings.py:725 msgid "English" msgstr "" -#: netbox/netbox/settings.py:724 +#: netbox/netbox/settings.py:726 msgid "Spanish" msgstr "" -#: netbox/netbox/settings.py:725 +#: netbox/netbox/settings.py:727 msgid "French" msgstr "" -#: netbox/netbox/settings.py:726 +#: netbox/netbox/settings.py:728 msgid "Japanese" msgstr "" -#: netbox/netbox/settings.py:727 +#: netbox/netbox/settings.py:729 msgid "Portuguese" msgstr "" -#: netbox/netbox/settings.py:728 +#: netbox/netbox/settings.py:730 msgid "Russian" msgstr "" -#: netbox/netbox/settings.py:729 +#: netbox/netbox/settings.py:731 msgid "Turkish" msgstr "" -#: netbox/netbox/settings.py:730 +#: netbox/netbox/settings.py:732 msgid "Ukrainian" msgstr "" -#: netbox/netbox/settings.py:731 +#: netbox/netbox/settings.py:733 msgid "Chinese" msgstr "" From 5788b6cb289cdc6f13da1e348f2af8061a78c08a Mon Sep 17 00:00:00 2001 From: Julio Oliveira at Encora <149191228+Julio-Oliveira-Encora@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:45:19 -0300 Subject: [PATCH 02/54] Fixes #14829 Simple condition (without and/or) does not work in event rule (#14870) --- netbox/extras/conditions.py | 30 ++++---- netbox/extras/tests/test_conditions.py | 96 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 39005b752..5680be444 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -135,23 +135,23 @@ class ConditionSet: def __init__(self, ruleset): if type(ruleset) is not dict: raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset))) - if len(ruleset) != 1: - raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format( - ruleset=len(ruleset))) - # Determine the logic type - logic = list(ruleset.keys())[0] - if type(logic) is not str or logic.lower() not in (AND, OR): - raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format( - logic=logic, op_and=AND, op_or=OR - )) - self.logic = logic.lower() + if len(ruleset) == 1: + self.logic = (list(ruleset.keys())[0]).lower() + if self.logic not in (AND, OR): + raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation.")) - # Compile the set of Conditions - self.conditions = [ - ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) - for rule in ruleset[self.logic] - ] + # Compile the set of Conditions + self.conditions = [ + ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) + for rule in ruleset[self.logic] + ] + else: + try: + self.logic = None + self.conditions = [Condition(**ruleset)] + except TypeError: + raise ValueError(_("Incorrect key(s) informed. Please check documentation.")) def eval(self, data): """ diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index e7275482a..dd528b918 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -1,6 +1,12 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from dcim.choices import SiteStatusChoices +from dcim.models import Site from extras.conditions import Condition, ConditionSet +from extras.events import serialize_for_event +from extras.forms import EventRuleForm +from extras.models import EventRule, Webhook class ConditionTestCase(TestCase): @@ -217,3 +223,93 @@ class ConditionSetTest(TestCase): self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) + + def test_event_rule_conditions_without_logic_operator(self): + """ + Test evaluation of EventRule conditions without logic operator. + """ + event_rule = EventRule( + name='Event Rule 1', + type_create=True, + type_update=True, + conditions={ + 'attr': 'status.value', + 'value': 'active', + } + ) + + # Create a Site to evaluate - Status = active + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE) + data = serialize_for_event(site) + + # Evaluate the conditions (status='active') + self.assertTrue(event_rule.eval_conditions(data)) + + def test_event_rule_conditions_with_logical_operation(self): + """ + Test evaluation of EventRule conditions without logic operator, but with logical operation (in). + """ + event_rule = EventRule( + name='Event Rule 1', + type_create=True, + type_update=True, + conditions={ + "attr": "status.value", + "value": ["planned", "staging"], + "op": "in", + } + ) + + # Create a Site to evaluate - Status = active + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE) + data = serialize_for_event(site) + + # Evaluate the conditions (status in ['planned, 'staging']) + self.assertFalse(event_rule.eval_conditions(data)) + + def test_event_rule_conditions_with_logical_operation_and_negate(self): + """ + Test evaluation of EventRule with logical operation (in) and negate. + """ + event_rule = EventRule( + name='Event Rule 1', + type_create=True, + type_update=True, + conditions={ + "attr": "status.value", + "value": ["planned", "staging"], + "op": "in", + "negate": True, + } + ) + + # Create a Site to evaluate - Status = active + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE) + data = serialize_for_event(site) + + # Evaluate the conditions (status NOT in ['planned, 'staging']) + self.assertTrue(event_rule.eval_conditions(data)) + + def test_event_rule_conditions_with_incorrect_key_must_return_false(self): + """ + Test Event Rule with incorrect condition (key "foo" is wrong). Must return false. + """ + + ct = ContentType.objects.get(app_label='extras', model='webhook') + site_ct = ContentType.objects.get_for_model(Site) + webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST') + form = EventRuleForm({ + "name": "Event Rule 1", + "type_create": True, + "type_update": True, + "action_object_type": ct.pk, + "action_type": "webhook", + "action_choice": webhook.pk, + "content_types": [site_ct.pk], + "conditions": { + "foo": "status.value", + "value": "active" + } + }) + + self.assertFalse(form.is_valid()) From e820c145f3b0f16db81c94ff9df69f3db8f58db0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Jun 2024 13:50:58 -0400 Subject: [PATCH 03/54] Skip CI for commits that only update translations --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a84359bf9..b4be03742 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,12 @@ on: paths-ignore: - 'contrib/**' - 'docs/**' + - 'netbox/translations/**' pull_request: paths-ignore: - 'contrib/**' - 'docs/**' + - 'netbox/translations/**' permissions: contents: read From 56b6b1b9d864a12d05b8cd6d9f4f1a476fa00e85 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Jun 2024 05:02:21 +0000 Subject: [PATCH 04/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index dcf1b4e6d..037f586f6 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-07 05:01+0000\n" +"POT-Creation-Date: 2024-06-08 05:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -6674,14 +6674,12 @@ msgstr "" msgid "Ruleset must be a dictionary, not {ruleset}." msgstr "" -#: netbox/extras/conditions.py:139 -#, python-brace-format -msgid "Ruleset must have exactly one logical operator (found {ruleset})" +#: netbox/extras/conditions.py:142 +msgid "Invalid logic type: must be 'AND' or 'OR'. Please check documentation." msgstr "" -#: netbox/extras/conditions.py:145 -#, python-brace-format -msgid "Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')" +#: netbox/extras/conditions.py:154 +msgid "Incorrect key(s) informed. Please check documentation." msgstr "" #: netbox/extras/dashboard/forms.py:38 From eb3d4230778f26fa5be38c7d6b3e1205f905fc93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Jun 2024 12:17:07 -0400 Subject: [PATCH 05/54] Fixes #16454: Roll back django-debug-toolbar version to avoid DNS looukp bug --- base_requirements.txt | 4 +++- requirements.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 9912f1d6b..a30ed0a90 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -8,7 +8,9 @@ django-cors-headers # Runtime UI tool for debugging Django # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst -django-debug-toolbar +# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454 +# and https://github.com/jazzband/django-debug-toolbar/issues/1927 +django-debug-toolbar==4.3.0 # Library for writing reusable URL query filters # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst diff --git a/requirements.txt b/requirements.txt index 761db2948..7e36996ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Django==5.0.6 django-cors-headers==4.3.1 -django-debug-toolbar==4.4.2 +django-debug-toolbar==4.3.0 django-filter==24.2 django-htmx==1.17.3 django-graphiql-debug-toolbar==0.2.0 From d85cf9ee0dea8582f8b5cb03d28ef62d51f1fc84 Mon Sep 17 00:00:00 2001 From: Julio Oliveira at Encora <149191228+Julio-Oliveira-Encora@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:21:24 -0300 Subject: [PATCH 06/54] 16256 - Allow alphabetical ordering of bookmarks on dashboard (#16426) * Added alphabetical ordering of bookmarks. * Addressed PR comments. * Rename choice constants & fix unrelated typo --------- Co-authored-by: Jeremy Stretch --- netbox/extras/choices.py | 4 ++++ netbox/extras/dashboard/widgets.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 2c9d5836a..12e10f553 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet): ORDERING_NEWEST = '-created' ORDERING_OLDEST = 'created' + ORDERING_ALPHABETICAL_AZ = 'name' + ORDERING_ALPHABETICAL_ZA = '-name' CHOICES = ( (ORDERING_NEWEST, _('Newest')), (ORDERING_OLDEST, _('Oldest')), + (ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')), + (ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')), ) # diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index add81a318..c4710468b 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget): if request.user.is_anonymous: bookmarks = list() else: - bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) + user_bookmarks = Bookmark.objects.filter(user=request.user) + if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ: + bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower()) + elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA: + bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True) + else: + bookmarks = user_bookmarks.order_by(self.config['order_by']) if object_types := self.config.get('object_types'): models = get_models_from_content_types(object_types) - conent_types = ObjectType.objects.get_for_models(*models).values() - bookmarks = bookmarks.filter(object_type__in=conent_types) + content_types = ObjectType.objects.get_for_models(*models).values() + bookmarks = bookmarks.filter(object_type__in=content_types) if max_items := self.config.get('max_items'): bookmarks = bookmarks[:max_items] From fbe64cb9a480ee56b69ed4598f295fe5c68c0275 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 05:02:10 +0000 Subject: [PATCH 07/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 92 +++++++++++--------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 037f586f6..b8b3cf9a3 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-08 05:02+0000\n" +"POT-Creation-Date: 2024-06-12 05:01+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,7 +29,7 @@ msgid "Write Enabled" msgstr "" #: netbox/account/tables.py:35 netbox/core/tables/jobs.py:29 -#: netbox/core/tables/tasks.py:79 netbox/extras/choices.py:138 +#: netbox/core/tables/tasks.py:79 netbox/extras/choices.py:142 #: netbox/extras/tables/tables.py:499 netbox/templates/account/token.html:43 #: netbox/templates/core/configrevision.html:26 #: netbox/templates/core/configrevision_restore.html:12 @@ -1400,7 +1400,7 @@ msgid "Syncing" msgstr "" #: netbox/core/choices.py:21 netbox/core/choices.py:57 -#: netbox/core/tables/jobs.py:41 netbox/extras/choices.py:224 +#: netbox/core/tables/jobs.py:41 netbox/extras/choices.py:228 #: netbox/templates/core/job.html:68 msgid "Completed" msgstr "" @@ -1408,7 +1408,7 @@ msgstr "" #: netbox/core/choices.py:22 netbox/core/choices.py:59 #: netbox/core/constants.py:20 netbox/core/tables/tasks.py:34 #: netbox/dcim/choices.py:176 netbox/dcim/choices.py:222 -#: netbox/dcim/choices.py:1536 netbox/extras/choices.py:226 +#: netbox/dcim/choices.py:1536 netbox/extras/choices.py:230 #: netbox/virtualization/choices.py:47 msgid "Failed" msgstr "" @@ -1426,21 +1426,21 @@ msgstr "" msgid "Reports" msgstr "" -#: netbox/core/choices.py:54 netbox/extras/choices.py:221 +#: netbox/core/choices.py:54 netbox/extras/choices.py:225 msgid "Pending" msgstr "" #: netbox/core/choices.py:55 netbox/core/constants.py:23 #: netbox/core/tables/jobs.py:32 netbox/core/tables/tasks.py:38 -#: netbox/extras/choices.py:222 netbox/templates/core/job.html:55 +#: netbox/extras/choices.py:226 netbox/templates/core/job.html:55 msgid "Scheduled" msgstr "" -#: netbox/core/choices.py:56 netbox/extras/choices.py:223 +#: netbox/core/choices.py:56 netbox/extras/choices.py:227 msgid "Running" msgstr "" -#: netbox/core/choices.py:58 netbox/extras/choices.py:225 +#: netbox/core/choices.py:58 netbox/extras/choices.py:229 msgid "Errored" msgstr "" @@ -6483,71 +6483,79 @@ msgstr "" msgid "Link" msgstr "" -#: netbox/extras/choices.py:122 +#: netbox/extras/choices.py:124 msgid "Newest" msgstr "" -#: netbox/extras/choices.py:123 +#: netbox/extras/choices.py:125 msgid "Oldest" msgstr "" -#: netbox/extras/choices.py:139 netbox/templates/generic/object.html:61 +#: netbox/extras/choices.py:126 +msgid "Alphabetical (A-Z)" +msgstr "" + +#: netbox/extras/choices.py:127 +msgid "Alphabetical (Z-A)" +msgstr "" + +#: netbox/extras/choices.py:143 netbox/templates/generic/object.html:61 msgid "Updated" msgstr "" -#: netbox/extras/choices.py:140 +#: netbox/extras/choices.py:144 msgid "Deleted" msgstr "" -#: netbox/extras/choices.py:157 netbox/extras/choices.py:181 +#: netbox/extras/choices.py:161 netbox/extras/choices.py:185 msgid "Info" msgstr "" -#: netbox/extras/choices.py:158 netbox/extras/choices.py:180 +#: netbox/extras/choices.py:162 netbox/extras/choices.py:184 msgid "Success" msgstr "" -#: netbox/extras/choices.py:159 netbox/extras/choices.py:182 +#: netbox/extras/choices.py:163 netbox/extras/choices.py:186 msgid "Warning" msgstr "" -#: netbox/extras/choices.py:160 +#: netbox/extras/choices.py:164 msgid "Danger" msgstr "" -#: netbox/extras/choices.py:178 +#: netbox/extras/choices.py:182 msgid "Debug" msgstr "" -#: netbox/extras/choices.py:179 netbox/netbox/choices.py:104 +#: netbox/extras/choices.py:183 netbox/netbox/choices.py:104 msgid "Default" msgstr "" -#: netbox/extras/choices.py:183 +#: netbox/extras/choices.py:187 msgid "Failure" msgstr "" -#: netbox/extras/choices.py:199 +#: netbox/extras/choices.py:203 msgid "Hourly" msgstr "" -#: netbox/extras/choices.py:200 +#: netbox/extras/choices.py:204 msgid "12 hours" msgstr "" -#: netbox/extras/choices.py:201 +#: netbox/extras/choices.py:205 msgid "Daily" msgstr "" -#: netbox/extras/choices.py:202 +#: netbox/extras/choices.py:206 msgid "Weekly" msgstr "" -#: netbox/extras/choices.py:203 +#: netbox/extras/choices.py:207 msgid "30 days" msgstr "" -#: netbox/extras/choices.py:268 netbox/extras/tables/tables.py:296 +#: netbox/extras/choices.py:272 netbox/extras/tables/tables.py:296 #: netbox/templates/dcim/virtualchassis_edit.html:107 #: netbox/templates/extras/eventrule.html:40 #: netbox/templates/generic/bulk_add_component.html:68 @@ -6557,12 +6565,12 @@ msgstr "" msgid "Create" msgstr "" -#: netbox/extras/choices.py:269 netbox/extras/tables/tables.py:299 +#: netbox/extras/choices.py:273 netbox/extras/tables/tables.py:299 #: netbox/templates/extras/eventrule.html:44 msgid "Update" msgstr "" -#: netbox/extras/choices.py:270 netbox/extras/tables/tables.py:302 +#: netbox/extras/choices.py:274 netbox/extras/tables/tables.py:302 #: netbox/templates/circuits/inc/circuit_termination.html:23 #: netbox/templates/dcim/inc/panels/inventory_items.html:37 #: netbox/templates/dcim/moduletype/component_templates.html:23 @@ -6579,77 +6587,77 @@ msgstr "" msgid "Delete" msgstr "" -#: netbox/extras/choices.py:294 netbox/netbox/choices.py:57 +#: netbox/extras/choices.py:298 netbox/netbox/choices.py:57 #: netbox/netbox/choices.py:105 msgid "Blue" msgstr "" -#: netbox/extras/choices.py:295 netbox/netbox/choices.py:56 +#: netbox/extras/choices.py:299 netbox/netbox/choices.py:56 #: netbox/netbox/choices.py:106 msgid "Indigo" msgstr "" -#: netbox/extras/choices.py:296 netbox/netbox/choices.py:54 +#: netbox/extras/choices.py:300 netbox/netbox/choices.py:54 #: netbox/netbox/choices.py:107 msgid "Purple" msgstr "" -#: netbox/extras/choices.py:297 netbox/netbox/choices.py:51 +#: netbox/extras/choices.py:301 netbox/netbox/choices.py:51 #: netbox/netbox/choices.py:108 msgid "Pink" msgstr "" -#: netbox/extras/choices.py:298 netbox/netbox/choices.py:50 +#: netbox/extras/choices.py:302 netbox/netbox/choices.py:50 #: netbox/netbox/choices.py:109 msgid "Red" msgstr "" -#: netbox/extras/choices.py:299 netbox/netbox/choices.py:68 +#: netbox/extras/choices.py:303 netbox/netbox/choices.py:68 #: netbox/netbox/choices.py:110 msgid "Orange" msgstr "" -#: netbox/extras/choices.py:300 netbox/netbox/choices.py:66 +#: netbox/extras/choices.py:304 netbox/netbox/choices.py:66 #: netbox/netbox/choices.py:111 msgid "Yellow" msgstr "" -#: netbox/extras/choices.py:301 netbox/netbox/choices.py:63 +#: netbox/extras/choices.py:305 netbox/netbox/choices.py:63 #: netbox/netbox/choices.py:112 msgid "Green" msgstr "" -#: netbox/extras/choices.py:302 netbox/netbox/choices.py:60 +#: netbox/extras/choices.py:306 netbox/netbox/choices.py:60 #: netbox/netbox/choices.py:113 msgid "Teal" msgstr "" -#: netbox/extras/choices.py:303 netbox/netbox/choices.py:59 +#: netbox/extras/choices.py:307 netbox/netbox/choices.py:59 #: netbox/netbox/choices.py:114 msgid "Cyan" msgstr "" -#: netbox/extras/choices.py:304 netbox/netbox/choices.py:115 +#: netbox/extras/choices.py:308 netbox/netbox/choices.py:115 msgid "Gray" msgstr "" -#: netbox/extras/choices.py:305 netbox/netbox/choices.py:74 +#: netbox/extras/choices.py:309 netbox/netbox/choices.py:74 #: netbox/netbox/choices.py:116 msgid "Black" msgstr "" -#: netbox/extras/choices.py:306 netbox/netbox/choices.py:75 +#: netbox/extras/choices.py:310 netbox/netbox/choices.py:75 #: netbox/netbox/choices.py:117 msgid "White" msgstr "" -#: netbox/extras/choices.py:320 netbox/extras/forms/model_forms.py:242 +#: netbox/extras/choices.py:324 netbox/extras/forms/model_forms.py:242 #: netbox/extras/forms/model_forms.py:324 #: netbox/templates/extras/webhook.html:10 msgid "Webhook" msgstr "" -#: netbox/extras/choices.py:321 netbox/extras/forms/model_forms.py:312 +#: netbox/extras/choices.py:325 netbox/extras/forms/model_forms.py:312 #: netbox/templates/extras/script/base.html:29 msgid "Script" msgstr "" From 763d65bed9e7fd27bbf6b65ed8063bd467ebe9f0 Mon Sep 17 00:00:00 2001 From: Julio Oliveira at Encora <149191228+Julio-Oliveira-Encora@users.noreply.github.com> Date: Wed, 12 Jun 2024 10:23:49 -0300 Subject: [PATCH 08/54] Added current time zone to render method in DateTimeColumn (#16323) --- netbox/netbox/tables/columns.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c37bb1b0d..cfe6c9be6 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -1,3 +1,4 @@ +import zoneinfo from dataclasses import dataclass from typing import Optional from urllib.parse import quote @@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column): def render(self, value): if value: + current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) + value = value.astimezone(current_tz) return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}" def value(self, value): From 5353f837108ee5db11537bc95dde3ce9bc4a042c Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Wed, 12 Jun 2024 15:46:41 +0200 Subject: [PATCH 09/54] 15794 Make "related objects" dynamic (#15876) * Closes #15794: Make "related objects" dynamic Instead of hardcoding relationships between models for the detail view, they are now dynamically generated. * Fix related models call * Remove extra related models hook Instead of providing a rarely used hook method, additional related models can now be passed directly to the lookup method. * Fix relations view for ASNs ASNs have ManyToMany relationships and therefore can't used automatic resolving. Explicit relations have been restored as before. * Add method call keywords for clarification * Cleanup related models --------- Co-authored-by: Jeremy Stretch --- netbox/circuits/views.py | 47 ++++---- netbox/core/views.py | 10 +- netbox/dcim/views.py | 189 +++++++++++++-------------------- netbox/ipam/views.py | 53 ++++----- netbox/tenancy/views.py | 34 ++---- netbox/utilities/views.py | 44 ++++++++ netbox/virtualization/views.py | 18 +--- netbox/vpn/views.py | 10 +- netbox/wireless/views.py | 9 +- 9 files changed, 176 insertions(+), 238 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index def9a3640..b10b83b23 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -7,7 +7,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView): @register_model_view(Provider) -class ProviderView(generic.ObjectView): +class ProviderView(GetRelatedModelsMixin, generic.ObjectView): queryset = Provider.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'), - (Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView): @register_model_view(ProviderAccount) -class ProviderAccountView(generic.ObjectView): +class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView): queryset = ProviderAccount.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView): @register_model_view(ProviderNetwork) -class ProviderNetworkView(generic.ObjectView): +class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): queryset = ProviderNetwork.objects.all() def get_extra_context(self, request, instance): - related_models = ( - ( - Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), - 'provider_network_id', - ), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), + 'provider_network_id', + ), + ), + ), } @@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView): @register_model_view(CircuitType) -class CircuitTypeView(generic.ObjectView): +class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = CircuitType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/core/views.py b/netbox/core/views.py index ded49c0b8..e454f109e 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.query import count_related -from utilities.views import ContentTypePermissionRequiredMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView): @register_model_view(DataSource) -class DataSourceView(generic.ObjectView): +class DataSourceView(GetRelatedModelsMixin, generic.ObjectView): queryset = DataSource.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 670995231..3b8c862a7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView -from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup +from ipam.models import ASN, IPAddress, VLANGroup from ipam.tables import InterfaceVLANTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic @@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view +from utilities.views import ( + GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view +) from virtualization.filtersets import VirtualMachineFilterSet from virtualization.models import VirtualMachine from virtualization.tables import VirtualMachineTable @@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) -class RegionView(generic.ObjectView): +class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'), - (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), - (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + regions, + extra=( + (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + ), + ), } @@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) -class SiteGroupView(generic.ObjectView): +class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), - (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + groups, + extra=( + (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + ), + ), } @@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) -class SiteView(generic.ObjectView): +class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') def get_extra_context(self, request, instance): - related_models = ( - # DCIM - (Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - # Virtualization - (VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'), - # IPAM - (Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), - (VLANGroup.objects.restrict(request.user, 'view').filter( - scope_type=ContentType.objects.get_for_model(Site), - scope_id=instance.pk - ), 'site'), - (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - # Circuits - (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + [CableTermination, CircuitTermination], + ( + (VLANGroup.objects.restrict(request.user, 'view').filter( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=instance.pk + ), 'site'), + (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), + (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), + 'site_id'), + ), + ), } @@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) -class LocationView(generic.ObjectView): +class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) - related_models = ( - (Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), - (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, locations, [CableTermination]), } @@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) -class RackRoleView(generic.ObjectView): +class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView): @register_model_view(Rack) -class RackView(generic.ObjectView): +class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'), - (PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'), - ) - peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) if instance.location: @@ -679,7 +665,7 @@ class RackView(generic.ObjectView): ]) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, [CableTermination]), 'next_rack': next_rack, 'prev_rack': prev_rack, 'svg_extra': svg_extra, @@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView): @register_model_view(Manufacturer) -class ManufacturerView(generic.ObjectView): +class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView): queryset = Manufacturer.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]), } @@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView): @register_model_view(DeviceType) -class DeviceTypeView(generic.ObjectView): +class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[ + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, + InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, + RearPortTemplate, + ]), } @@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView): @register_model_view(ModuleType) -class ModuleTypeView(generic.ObjectView): +class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[ + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, + InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, + RearPortTemplate, + ]), } @@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView): @register_model_view(DeviceRole) -class DeviceRoleView(generic.ObjectView): +class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView): @register_model_view(Platform) -class PlatformView(generic.ObjectView): +class PlatformView(GetRelatedModelsMixin, generic.ObjectView): queryset = Platform.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), - (VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView): @register_model_view(Module) -class ModuleView(generic.ObjectView): +class ModuleView(GetRelatedModelsMixin, generic.ObjectView): queryset = Module.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -3552,16 +3511,12 @@ class PowerPanelListView(generic.ObjectListView): @register_model_view(PowerPanel) -class PowerPanelView(generic.ObjectView): +class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView): queryset = PowerPanel.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -3665,16 +3620,18 @@ class VirtualDeviceContextListView(generic.ObjectListView): @register_model_view(VirtualDeviceContext) -class VirtualDeviceContextView(generic.ObjectView): +class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView): queryset = VirtualDeviceContext.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'), + ), + ), } diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f94c3c6d7..12c86c533 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -12,7 +12,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.tables import get_table_ordering -from utilities.views import ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface from . import filtersets, forms, tables @@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView): @register_model_view(VRF) -class VRFView(generic.ObjectView): +class VRFView(GetRelatedModelsMixin, generic.ObjectView): queryset = VRF.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), - (IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), - ) - import_targets_table = tables.RouteTargetTable( instance.import_targets.all(), orderable=False @@ -53,7 +48,7 @@ class VRFView(generic.ObjectView): ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]), 'import_targets_table': import_targets_table, 'export_targets_table': export_targets_table, } @@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView): @register_model_view(RIR) -class RIRView(generic.ObjectView): +class RIRView(GetRelatedModelsMixin, generic.ObjectView): queryset = RIR.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView): @register_model_view(ASN) -class ASNView(generic.ObjectView): +class ASNView(GetRelatedModelsMixin, generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), - (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + ), + ), } @@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView): @register_model_view(Role) -class RoleView(generic.ObjectView): +class RoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = Role.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView): @register_model_view(VLANGroup) -class VLANGroupView(generic.ObjectView): +class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') def get_extra_context(self, request, instance): - related_models = ( - (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 03dcc94bd..06fbcc575 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -4,8 +4,7 @@ from django.utils.translation import gettext as _ from netbox.views import generic from utilities.query import count_related -from utilities.relations import get_related_models -from utilities.views import register_model_view, ViewTab +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .models import * @@ -56,17 +55,14 @@ class TenantGroupListView(generic.ObjectListView): @register_model_view(TenantGroup) -class TenantGroupView(generic.ObjectView): +class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = TenantGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), } @@ -123,17 +119,12 @@ class TenantListView(generic.ObjectListView): @register_model_view(Tenant) -class TenantView(generic.ObjectView): +class TenantView(GetRelatedModelsMixin, generic.ObjectView): queryset = Tenant.objects.all() def get_extra_context(self, request, instance): - related_models = [ - (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id') - for model, field in get_related_models(Tenant) - ] - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -189,17 +180,14 @@ class ContactGroupListView(generic.ObjectListView): @register_model_view(ContactGroup) -class ContactGroupView(generic.ObjectView): +class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ContactGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), } @@ -256,16 +244,12 @@ class ContactRoleListView(generic.ObjectListView): @register_model_view(ContactRole) -class ContactRoleView(generic.ObjectView): +class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = ContactRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 4bca48dbd..75c48b01f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,3 +1,5 @@ +from typing import Iterable + from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured from django.urls import reverse @@ -6,10 +8,12 @@ from django.utils.translation import gettext_lazy as _ from netbox.plugins import PluginConfig from netbox.registry import registry +from utilities.relations import get_related_models from .permissions import resolve_permission __all__ = ( 'ContentTypePermissionRequiredMixin', + 'GetRelatedModelsMixin', 'GetReturnURLMixin', 'ObjectPermissionRequiredMixin', 'ViewTab', @@ -142,6 +146,46 @@ class GetReturnURLMixin: return reverse('home') +class GetRelatedModelsMixin: + """ + Provides logic for collecting all related models for the currently viewed model. + """ + + def get_related_models(self, request, instance, omit=[], extra=[]): + """ + Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical. + + Args: + request: Current request being processed. + instance: The instance related models should be looked up for. A list of instances can be passed to match + related objects in this list (e.g. to find sites of a region including child regions). + omit: Remove relationships to these models from the result. Needs to be passed, if related models don't + provide a `_list` view. + extra: Add extra models to the list of automatically determined related models. Can be used to add indirect + relationships. + """ + model = self.queryset.model + related = filter( + lambda m: m[0] is not model and m[0] not in omit, + get_related_models(model, False) + ) + + related_models = [ + ( + model.objects.restrict(request.user, 'view').filter(**( + {f'{field}__in': instance} + if isinstance(instance, Iterable) + else {field: instance} + )), + f'{field}_id' + ) + for model, field in related + ] + related_models.extend(extra) + + return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower()) + + class ViewTab: """ ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 56d8feb26..c143fff85 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.views import ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .models import * @@ -39,16 +39,12 @@ class ClusterTypeListView(generic.ObjectListView): @register_model_view(ClusterType) -class ClusterTypeView(generic.ObjectView): +class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -99,16 +95,12 @@ class ClusterGroupListView(generic.ObjectListView): @register_model_view(ClusterGroup) -class ClusterGroupView(generic.ObjectView): +class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterGroup.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index b2dcf4038..ac8ce3667 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -2,7 +2,7 @@ from ipam.tables import RouteTargetTable from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -21,16 +21,12 @@ class TunnelGroupListView(generic.ObjectListView): @register_model_view(TunnelGroup) -class TunnelGroupView(generic.ObjectView): +class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = TunnelGroup.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 891bb6f84..5063f0fee 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,7 +1,7 @@ from dcim.models import Interface from netbox.views import generic from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -24,17 +24,14 @@ class WirelessLANGroupListView(generic.ObjectListView): @register_model_view(WirelessLANGroup) -class WirelessLANGroupView(generic.ObjectView): +class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = WirelessLANGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), } From 83da49cfa3950f5acb1e48bb5aa9943999a782cf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Jun 2024 12:28:27 -0400 Subject: [PATCH 10/54] Update release checklist to include building public docs --- docs/development/release-checklist.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 4f6e2f25f..91162f08a 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -126,3 +126,13 @@ VERSION = 'v3.3.2-dev' ``` Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream. + +### Update the Public Documentation + +After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository. + +First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at . The job should take about two minutes. + +Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag. + +Finally, verify that the documentation at has been updated. From a597ad849e35a5445b34cab3076217076f2f85c9 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Jun 2024 05:02:20 +0000 Subject: [PATCH 11/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 106 +++++++++---------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index b8b3cf9a3..7a60e6028 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-12 05:01+0000\n" +"POT-Creation-Date: 2024-06-13 05:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -325,7 +325,7 @@ msgstr "" #: netbox/circuits/tables/providers.py:33 netbox/dcim/forms/bulk_edit.py:127 #: netbox/dcim/forms/filtersets.py:188 netbox/dcim/forms/model_forms.py:122 #: netbox/dcim/tables/sites.py:94 netbox/ipam/models/asns.py:126 -#: netbox/ipam/tables/asn.py:27 netbox/ipam/views.py:219 +#: netbox/ipam/tables/asn.py:27 netbox/ipam/views.py:210 #: netbox/netbox/navigation/menu.py:159 netbox/netbox/navigation/menu.py:162 #: netbox/templates/circuits/provider.html:23 msgid "ASNs" @@ -895,7 +895,7 @@ msgstr "" #: netbox/dcim/forms/filtersets.py:653 netbox/dcim/forms/filtersets.py:1010 #: netbox/netbox/navigation/menu.py:44 netbox/netbox/navigation/menu.py:46 #: netbox/tenancy/forms/filtersets.py:42 netbox/tenancy/tables/columns.py:70 -#: netbox/tenancy/tables/contacts.py:25 netbox/tenancy/views.py:19 +#: netbox/tenancy/tables/contacts.py:25 netbox/tenancy/views.py:18 #: netbox/virtualization/forms/filtersets.py:37 #: netbox/virtualization/forms/filtersets.py:48 #: netbox/virtualization/forms/filtersets.py:106 @@ -2067,8 +2067,8 @@ msgstr "" msgid "No workers found" msgstr "" -#: netbox/core/views.py:335 netbox/core/views.py:378 netbox/core/views.py:401 -#: netbox/core/views.py:419 netbox/core/views.py:454 +#: netbox/core/views.py:331 netbox/core/views.py:374 netbox/core/views.py:397 +#: netbox/core/views.py:415 netbox/core/views.py:450 #, python-brace-format msgid "Job {job_id} not found" msgstr "" @@ -2946,7 +2946,7 @@ msgstr "" #: netbox/dcim/forms/bulk_create.py:40 netbox/extras/forms/filtersets.py:410 #: netbox/extras/forms/model_forms.py:443 #: netbox/extras/forms/model_forms.py:495 netbox/netbox/forms/base.py:84 -#: netbox/netbox/forms/mixins.py:81 netbox/netbox/tables/columns.py:458 +#: netbox/netbox/forms/mixins.py:81 netbox/netbox/tables/columns.py:461 #: netbox/templates/circuits/inc/circuit_termination.html:32 #: netbox/templates/generic/bulk_edit.html:65 #: netbox/templates/inc/panels/tags.html:5 @@ -5974,7 +5974,7 @@ msgstr "" #: netbox/netbox/navigation/menu.py:60 netbox/netbox/navigation/menu.py:62 #: netbox/virtualization/forms/model_forms.py:122 #: netbox/virtualization/tables/clusters.py:83 -#: netbox/virtualization/views.py:210 +#: netbox/virtualization/views.py:202 msgid "Devices" msgstr "" @@ -6054,8 +6054,8 @@ msgid "Power outlets" msgstr "" #: netbox/dcim/tables/devices.py:243 netbox/dcim/tables/devices.py:1046 -#: netbox/dcim/tables/devicetypes.py:125 netbox/dcim/views.py:1006 -#: netbox/dcim/views.py:1245 netbox/dcim/views.py:1931 +#: netbox/dcim/tables/devicetypes.py:125 netbox/dcim/views.py:985 +#: netbox/dcim/views.py:1224 netbox/dcim/views.py:1900 #: netbox/netbox/navigation/menu.py:81 netbox/netbox/navigation/menu.py:237 #: netbox/templates/dcim/device/base.html:37 #: netbox/templates/dcim/device_list.html:43 @@ -6067,7 +6067,7 @@ msgstr "" #: netbox/templates/virtualization/virtualmachine/base.html:27 #: netbox/templates/virtualization/virtualmachine_list.html:14 #: netbox/virtualization/tables/virtualmachines.py:100 -#: netbox/virtualization/views.py:367 netbox/wireless/tables/wirelesslan.py:55 +#: netbox/virtualization/views.py:359 netbox/wireless/tables/wirelesslan.py:55 msgid "Interfaces" msgstr "" @@ -6093,8 +6093,8 @@ msgid "Module Bay" msgstr "" #: netbox/dcim/tables/devices.py:310 netbox/dcim/tables/devicetypes.py:48 -#: netbox/dcim/tables/devicetypes.py:140 netbox/dcim/views.py:1081 -#: netbox/dcim/views.py:2024 netbox/netbox/navigation/menu.py:90 +#: netbox/dcim/tables/devicetypes.py:140 netbox/dcim/views.py:1060 +#: netbox/dcim/views.py:1993 netbox/netbox/navigation/menu.py:90 #: netbox/templates/dcim/device/base.html:52 #: netbox/templates/dcim/device_list.html:71 #: netbox/templates/dcim/devicetype/base.html:49 @@ -6124,8 +6124,8 @@ msgid "Allocated draw (W)" msgstr "" #: netbox/dcim/tables/devices.py:546 netbox/ipam/forms/model_forms.py:747 -#: netbox/ipam/tables/fhrp.py:28 netbox/ipam/views.py:602 -#: netbox/ipam/views.py:701 netbox/netbox/navigation/menu.py:145 +#: netbox/ipam/tables/fhrp.py:28 netbox/ipam/views.py:589 +#: netbox/ipam/views.py:688 netbox/netbox/navigation/menu.py:145 #: netbox/netbox/navigation/menu.py:147 #: netbox/templates/dcim/interface.html:339 #: netbox/templates/ipam/ipaddress_bulk_add.html:15 @@ -6218,8 +6218,8 @@ msgstr "" msgid "Instances" msgstr "" -#: netbox/dcim/tables/devicetypes.py:113 netbox/dcim/views.py:946 -#: netbox/dcim/views.py:1185 netbox/dcim/views.py:1871 +#: netbox/dcim/tables/devicetypes.py:113 netbox/dcim/views.py:925 +#: netbox/dcim/views.py:1164 netbox/dcim/views.py:1840 #: netbox/netbox/navigation/menu.py:84 #: netbox/templates/dcim/device/base.html:25 #: netbox/templates/dcim/device_list.html:15 @@ -6229,8 +6229,8 @@ msgstr "" msgid "Console Ports" msgstr "" -#: netbox/dcim/tables/devicetypes.py:116 netbox/dcim/views.py:961 -#: netbox/dcim/views.py:1200 netbox/dcim/views.py:1886 +#: netbox/dcim/tables/devicetypes.py:116 netbox/dcim/views.py:940 +#: netbox/dcim/views.py:1179 netbox/dcim/views.py:1855 #: netbox/netbox/navigation/menu.py:85 #: netbox/templates/dcim/device/base.html:28 #: netbox/templates/dcim/device_list.html:22 @@ -6240,8 +6240,8 @@ msgstr "" msgid "Console Server Ports" msgstr "" -#: netbox/dcim/tables/devicetypes.py:119 netbox/dcim/views.py:976 -#: netbox/dcim/views.py:1215 netbox/dcim/views.py:1901 +#: netbox/dcim/tables/devicetypes.py:119 netbox/dcim/views.py:955 +#: netbox/dcim/views.py:1194 netbox/dcim/views.py:1870 #: netbox/netbox/navigation/menu.py:86 #: netbox/templates/dcim/device/base.html:31 #: netbox/templates/dcim/device_list.html:29 @@ -6251,8 +6251,8 @@ msgstr "" msgid "Power Ports" msgstr "" -#: netbox/dcim/tables/devicetypes.py:122 netbox/dcim/views.py:991 -#: netbox/dcim/views.py:1230 netbox/dcim/views.py:1916 +#: netbox/dcim/tables/devicetypes.py:122 netbox/dcim/views.py:970 +#: netbox/dcim/views.py:1209 netbox/dcim/views.py:1885 #: netbox/netbox/navigation/menu.py:87 #: netbox/templates/dcim/device/base.html:34 #: netbox/templates/dcim/device_list.html:36 @@ -6262,8 +6262,8 @@ msgstr "" msgid "Power Outlets" msgstr "" -#: netbox/dcim/tables/devicetypes.py:128 netbox/dcim/views.py:1021 -#: netbox/dcim/views.py:1260 netbox/dcim/views.py:1952 +#: netbox/dcim/tables/devicetypes.py:128 netbox/dcim/views.py:1000 +#: netbox/dcim/views.py:1239 netbox/dcim/views.py:1921 #: netbox/netbox/navigation/menu.py:82 #: netbox/templates/dcim/device/base.html:40 #: netbox/templates/dcim/devicetype/base.html:37 @@ -6272,8 +6272,8 @@ msgstr "" msgid "Front Ports" msgstr "" -#: netbox/dcim/tables/devicetypes.py:131 netbox/dcim/views.py:1036 -#: netbox/dcim/views.py:1275 netbox/dcim/views.py:1967 +#: netbox/dcim/tables/devicetypes.py:131 netbox/dcim/views.py:1015 +#: netbox/dcim/views.py:1254 netbox/dcim/views.py:1936 #: netbox/netbox/navigation/menu.py:83 #: netbox/templates/dcim/device/base.html:43 #: netbox/templates/dcim/device_list.html:50 @@ -6283,16 +6283,16 @@ msgstr "" msgid "Rear Ports" msgstr "" -#: netbox/dcim/tables/devicetypes.py:134 netbox/dcim/views.py:1066 -#: netbox/dcim/views.py:2005 netbox/netbox/navigation/menu.py:89 +#: netbox/dcim/tables/devicetypes.py:134 netbox/dcim/views.py:1045 +#: netbox/dcim/views.py:1974 netbox/netbox/navigation/menu.py:89 #: netbox/templates/dcim/device/base.html:49 #: netbox/templates/dcim/device_list.html:57 #: netbox/templates/dcim/devicetype/base.html:46 msgid "Device Bays" msgstr "" -#: netbox/dcim/tables/devicetypes.py:137 netbox/dcim/views.py:1051 -#: netbox/dcim/views.py:1986 netbox/netbox/navigation/menu.py:88 +#: netbox/dcim/tables/devicetypes.py:137 netbox/dcim/views.py:1030 +#: netbox/dcim/views.py:1955 netbox/netbox/navigation/menu.py:88 #: netbox/templates/dcim/device/base.html:46 #: netbox/templates/dcim/device_list.html:64 #: netbox/templates/dcim/devicetype/base.html:43 @@ -6350,38 +6350,38 @@ msgstr "" msgid "Test case must set peer_termination_type" msgstr "" -#: netbox/dcim/views.py:137 +#: netbox/dcim/views.py:139 #, python-brace-format msgid "Disconnected {count} {type}" msgstr "" -#: netbox/dcim/views.py:698 netbox/netbox/navigation/menu.py:28 +#: netbox/dcim/views.py:684 netbox/netbox/navigation/menu.py:28 msgid "Reservations" msgstr "" -#: netbox/dcim/views.py:716 netbox/templates/dcim/location.html:90 +#: netbox/dcim/views.py:702 netbox/templates/dcim/location.html:90 #: netbox/templates/dcim/site.html:140 msgid "Non-Racked Devices" msgstr "" -#: netbox/dcim/views.py:2037 netbox/extras/forms/model_forms.py:453 +#: netbox/dcim/views.py:2006 netbox/extras/forms/model_forms.py:453 #: netbox/templates/extras/configcontext.html:10 #: netbox/virtualization/forms/model_forms.py:225 -#: netbox/virtualization/views.py:407 +#: netbox/virtualization/views.py:399 msgid "Config Context" msgstr "" -#: netbox/dcim/views.py:2047 netbox/virtualization/views.py:417 +#: netbox/dcim/views.py:2016 netbox/virtualization/views.py:409 msgid "Render Config" msgstr "" -#: netbox/dcim/views.py:2097 netbox/extras/tables/tables.py:440 +#: netbox/dcim/views.py:2066 netbox/extras/tables/tables.py:440 #: netbox/netbox/navigation/menu.py:234 netbox/netbox/navigation/menu.py:236 -#: netbox/virtualization/views.py:185 +#: netbox/virtualization/views.py:177 msgid "Virtual Machines" msgstr "" -#: netbox/dcim/views.py:2989 netbox/ipam/tables/ip.py:233 +#: netbox/dcim/views.py:2948 netbox/ipam/tables/ip.py:233 msgid "Children" msgstr "" @@ -9415,7 +9415,7 @@ msgid "The primary function of this VLAN" msgstr "" #: netbox/ipam/models/vlans.py:215 netbox/ipam/tables/ip.py:175 -#: netbox/ipam/tables/vlans.py:78 netbox/ipam/views.py:978 +#: netbox/ipam/tables/vlans.py:78 netbox/ipam/views.py:961 #: netbox/netbox/navigation/menu.py:180 netbox/netbox/navigation/menu.py:182 msgid "VLANs" msgstr "" @@ -9487,7 +9487,7 @@ msgid "Added" msgstr "" #: netbox/ipam/tables/ip.py:127 netbox/ipam/tables/ip.py:165 -#: netbox/ipam/tables/vlans.py:138 netbox/ipam/views.py:349 +#: netbox/ipam/tables/vlans.py:138 netbox/ipam/views.py:342 #: netbox/netbox/navigation/menu.py:152 netbox/netbox/navigation/menu.py:154 #: netbox/templates/ipam/vlan.html:84 msgid "Prefixes" @@ -9588,23 +9588,23 @@ msgid "" "are allowed in DNS names" msgstr "" -#: netbox/ipam/views.py:541 +#: netbox/ipam/views.py:528 msgid "Child Prefixes" msgstr "" -#: netbox/ipam/views.py:576 +#: netbox/ipam/views.py:563 msgid "Child Ranges" msgstr "" -#: netbox/ipam/views.py:902 +#: netbox/ipam/views.py:889 msgid "Related IPs" msgstr "" -#: netbox/ipam/views.py:1133 +#: netbox/ipam/views.py:1116 msgid "Device Interfaces" msgstr "" -#: netbox/ipam/views.py:1150 +#: netbox/ipam/views.py:1133 msgid "VM Interfaces" msgstr "" @@ -10159,7 +10159,7 @@ msgstr "" #: netbox/templates/virtualization/virtualmachine/base.html:32 #: netbox/templates/virtualization/virtualmachine_list.html:21 #: netbox/virtualization/tables/virtualmachines.py:103 -#: netbox/virtualization/views.py:388 +#: netbox/virtualization/views.py:380 msgid "Virtual Disks" msgstr "" @@ -10498,15 +10498,15 @@ msgstr "" msgid "Chinese" msgstr "" -#: netbox/netbox/tables/columns.py:185 +#: netbox/netbox/tables/columns.py:188 msgid "Toggle all" msgstr "" -#: netbox/netbox/tables/columns.py:287 +#: netbox/netbox/tables/columns.py:290 msgid "Toggle Dropdown" msgstr "" -#: netbox/netbox/tables/columns.py:552 netbox/templates/core/job.html:35 +#: netbox/netbox/tables/columns.py:555 netbox/templates/core/job.html:35 msgid "Error" msgstr "" @@ -14072,17 +14072,17 @@ msgstr "" msgid "{value} is not a valid regular expression." msgstr "" -#: netbox/utilities/views.py:40 +#: netbox/utilities/views.py:44 #, python-brace-format msgid "{self.__class__.__name__} must implement get_required_permission()" msgstr "" -#: netbox/utilities/views.py:76 +#: netbox/utilities/views.py:80 #, python-brace-format msgid "{class_name} must implement get_required_permission()" msgstr "" -#: netbox/utilities/views.py:100 +#: netbox/utilities/views.py:104 #, python-brace-format msgid "" "{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only " From b2360b62b51aa4965830b6b19b295a0a9ec79c2f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jun 2024 10:38:09 -0400 Subject: [PATCH 12/54] Fixes #13925: Support 'zulu' style timestamps for custom fields --- netbox/extras/models/customfields.py | 4 ++++ netbox/utilities/templates/builtins/customfield_value.html | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 974affb2e..240998146 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -660,6 +660,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Validate date & time elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: if type(value) is not datetime: + # Work around UTC issue for Python < 3.11; see + # https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat + if type(value) is str and value.endswith('Z'): + value = f'{value[:-1]}+00:00' try: datetime.fromisoformat(value) except ValueError: diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html index 462e62b86..dbf10e1bf 100644 --- a/netbox/utilities/templates/builtins/customfield_value.html +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -11,7 +11,7 @@ {% elif customfield.type == 'date' and value %} {{ value|isodate }} {% elif customfield.type == 'datetime' and value %} - {{ value|isodate }} {{ value|isodatetime }} + {{ value|isodatetime }} {% elif customfield.type == 'url' and value %} {{ value|truncatechars:70 }} {% elif customfield.type == 'json' and value %} From 49971dd7dbc8da653b31499bb9abb8eb9ebfb68f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jun 2024 10:56:03 -0400 Subject: [PATCH 13/54] Changelog for #13925, #14829, #15794, #16143, #16256, #16454 --- docs/release-notes/version-4.0.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index ae0578690..647b73231 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -2,6 +2,18 @@ ## v4.0.6 (FUTURE) +### Enhancements + +* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views +* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard + +### Bug Fixes + +* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields +* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules +* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone +* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar + --- ## v4.0.5 (2024-06-06) From c8aac13ceebf851368f6c7c2fbc6a783fc2e7bda Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Jun 2024 05:02:20 +0000 Subject: [PATCH 14/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 7a60e6028..17636c9e3 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-13 05:02+0000\n" +"POT-Creation-Date: 2024-06-15 05:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -7686,56 +7686,56 @@ msgstr "" msgid "Date values must be in ISO 8601 format (YYYY-MM-DD)." msgstr "" -#: netbox/extras/models/customfields.py:667 +#: netbox/extras/models/customfields.py:671 msgid "Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS)." msgstr "" -#: netbox/extras/models/customfields.py:674 +#: netbox/extras/models/customfields.py:678 #, python-brace-format msgid "Invalid choice ({value}) for choice set {choiceset}." msgstr "" -#: netbox/extras/models/customfields.py:684 +#: netbox/extras/models/customfields.py:688 #, python-brace-format msgid "Invalid choice(s) ({value}) for choice set {choiceset}." msgstr "" -#: netbox/extras/models/customfields.py:693 +#: netbox/extras/models/customfields.py:697 #, python-brace-format msgid "Value must be an object ID, not {type}" msgstr "" -#: netbox/extras/models/customfields.py:699 +#: netbox/extras/models/customfields.py:703 #, python-brace-format msgid "Value must be a list of object IDs, not {type}" msgstr "" -#: netbox/extras/models/customfields.py:703 +#: netbox/extras/models/customfields.py:707 #, python-brace-format msgid "Found invalid object ID: {id}" msgstr "" -#: netbox/extras/models/customfields.py:706 +#: netbox/extras/models/customfields.py:710 msgid "Required field cannot be empty." msgstr "" -#: netbox/extras/models/customfields.py:725 +#: netbox/extras/models/customfields.py:729 msgid "Base set of predefined choices (optional)" msgstr "" -#: netbox/extras/models/customfields.py:737 +#: netbox/extras/models/customfields.py:741 msgid "Choices are automatically ordered alphabetically" msgstr "" -#: netbox/extras/models/customfields.py:744 +#: netbox/extras/models/customfields.py:748 msgid "custom field choice set" msgstr "" -#: netbox/extras/models/customfields.py:745 +#: netbox/extras/models/customfields.py:749 msgid "custom field choice sets" msgstr "" -#: netbox/extras/models/customfields.py:781 +#: netbox/extras/models/customfields.py:785 msgid "Must define base or extra choices." msgstr "" From 853d990c033b638585c95da4ffa0889bb4f9f0ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 08:03:06 -0400 Subject: [PATCH 15/54] Closes #16388: Move change logging resources from `extras` to `core` (#16545) * Initial work on #16388 * Misc cleanup --- netbox/account/views.py | 6 +- netbox/core/api/serializers.py | 1 + .../api/serializers_/change_logging.py | 6 +- netbox/core/api/urls.py | 4 +- netbox/core/api/views.py | 11 ++ netbox/core/choices.py | 17 +++ netbox/core/filtersets.py | 41 +++++++ netbox/core/forms/filtersets.py | 41 ++++++- netbox/core/graphql/filters.py | 7 ++ netbox/core/graphql/mixins.py | 24 ++++ netbox/core/graphql/types.py | 10 ++ .../core/migrations/0011_move_objectchange.py | 45 ++++++++ netbox/core/models/__init__.py | 3 +- .../{extras => core}/models/change_logging.py | 8 +- netbox/core/querysets.py | 26 +++++ netbox/core/tables/__init__.py | 1 + netbox/core/tables/change_logging.py | 53 +++++++++ netbox/core/tables/template_code.py | 16 +++ .../{extras => core}/tests/test_changelog.py | 5 +- netbox/core/tests/test_filtersets.py | 104 +++++++++++++++++- netbox/core/tests/test_models.py | 2 +- netbox/core/tests/test_views.py | 44 +++++++- netbox/core/urls.py | 4 + netbox/core/views.py | 70 ++++++++++++ netbox/dcim/graphql/types.py | 8 +- netbox/extras/api/serializers.py | 1 - netbox/extras/api/urls.py | 1 - netbox/extras/api/views.py | 14 --- netbox/extras/choices.py | 17 --- netbox/extras/constants.py | 2 +- netbox/extras/events.py | 5 +- netbox/extras/filtersets.py | 38 ------- netbox/extras/forms/filtersets.py | 37 +------ netbox/extras/graphql/filters.py | 7 -- netbox/extras/graphql/mixins.py | 19 +--- netbox/extras/graphql/types.py | 10 -- .../management/commands/housekeeping.py | 3 +- .../extras/management/commands/runscript.py | 2 +- .../migrations/0116_move_objectchange.py | 57 ++++++++++ netbox/extras/models/__init__.py | 1 - netbox/extras/models/models.py | 3 +- netbox/extras/querysets.py | 20 ---- netbox/extras/scripts.py | 2 +- netbox/extras/signals.py | 6 +- netbox/extras/tables/tables.py | 44 -------- netbox/extras/tables/template_code.py | 17 --- netbox/extras/tests/test_event_rules.py | 5 +- netbox/extras/tests/test_filtersets.py | 101 +---------------- netbox/extras/tests/test_views.py | 40 ------- netbox/extras/urls.py | 4 - netbox/extras/views.py | 70 ------------ netbox/{extras => netbox}/context_managers.py | 2 +- netbox/netbox/filtersets.py | 6 +- netbox/netbox/graphql/types.py | 15 +-- netbox/netbox/middleware.py | 2 +- netbox/netbox/models/features.py | 5 +- netbox/netbox/navigation/menu.py | 2 +- netbox/netbox/views/generic/feature_views.py | 15 +-- .../{extras => core}/objectchange.html | 10 +- .../{extras => core}/objectchange_list.html | 0 netbox/users/views.py | 4 +- netbox/utilities/testing/api.py | 19 ++-- netbox/utilities/testing/views.py | 5 +- 63 files changed, 645 insertions(+), 523 deletions(-) rename netbox/{extras => core}/api/serializers_/change_logging.py (92%) create mode 100644 netbox/core/graphql/mixins.py create mode 100644 netbox/core/migrations/0011_move_objectchange.py rename netbox/{extras => core}/models/change_logging.py (97%) create mode 100644 netbox/core/querysets.py create mode 100644 netbox/core/tables/change_logging.py create mode 100644 netbox/core/tables/template_code.py rename netbox/{extras => core}/tests/test_changelog.py (99%) create mode 100644 netbox/extras/migrations/0116_move_objectchange.py rename netbox/{extras => netbox}/context_managers.py (94%) rename netbox/templates/{extras => core}/objectchange.html (90%) rename netbox/templates/{extras => core}/objectchange_list.html (100%) diff --git a/netbox/account/views.py b/netbox/account/views.py index 40ce78039..fa6970c6e 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -19,8 +19,10 @@ from django.views.generic import View from social_core.backends.utils import load_backends from account.models import UserToken -from extras.models import Bookmark, ObjectChange -from extras.tables import BookmarkTable, ObjectChangeTable +from core.models import ObjectChange +from core.tables import ObjectChangeTable +from extras.models import Bookmark +from extras.tables import BookmarkTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from netbox.views import generic diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index 8553bb91c..d59745ccd 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -1,3 +1,4 @@ +from .serializers_.change_logging import * from .serializers_.data import * from .serializers_.jobs import * from .nested_serializers import * diff --git a/netbox/extras/api/serializers_/change_logging.py b/netbox/core/api/serializers_/change_logging.py similarity index 92% rename from netbox/extras/api/serializers_/change_logging.py rename to netbox/core/api/serializers_/change_logging.py index 46fb901ff..ceb0c9b9a 100644 --- a/netbox/extras/api/serializers_/change_logging.py +++ b/netbox/core/api/serializers_/change_logging.py @@ -1,8 +1,8 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from extras.choices import * -from extras.models import ObjectChange +from core.choices import * +from core.models import ObjectChange from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import BaseModelSerializer @@ -15,7 +15,7 @@ __all__ = ( class ObjectChangeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') + url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail') user = UserSerializer( nested=True, read_only=True diff --git a/netbox/core/api/urls.py b/netbox/core/api/urls.py index 7017c3739..95ee1896e 100644 --- a/netbox/core/api/urls.py +++ b/netbox/core/api/urls.py @@ -5,12 +5,10 @@ from . import views router = NetBoxRouter() router.APIRootView = views.CoreRootView -# Data sources router.register('data-sources', views.DataSourceViewSet) router.register('data-files', views.DataFileViewSet) - -# Jobs router.register('jobs', views.JobViewSet) +router.register('object-changes', views.ObjectChangeViewSet) app_name = 'core-api' urlpatterns = router.urls diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index 6338523d2..ff488e3cd 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -8,6 +8,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from core import filtersets from core.models import * +from netbox.api.metadata import ContentTypeMetadata from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet from . import serializers @@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet): queryset = Job.objects.all() serializer_class = serializers.JobSerializer filterset_class = filtersets.JobFilterSet + + +class ObjectChangeViewSet(ReadOnlyModelViewSet): + """ + Retrieve a list of recent changes. + """ + metadata_class = ContentTypeMetadata + queryset = ObjectChange.objects.valid_models() + serializer_class = serializers.ObjectChangeSerializer + filterset_class = filtersets.ObjectChangeFilterSet diff --git a/netbox/core/choices.py b/netbox/core/choices.py index 8d7050414..ee0febaff 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -64,3 +64,20 @@ class JobStatusChoices(ChoiceSet): STATUS_ERRORED, STATUS_FAILED, ) + + +# +# ObjectChanges +# + +class ObjectChangeActionChoices(ChoiceSet): + + ACTION_CREATE = 'create' + ACTION_UPDATE = 'update' + ACTION_DELETE = 'delete' + + CHOICES = ( + (ACTION_CREATE, _('Created'), 'green'), + (ACTION_UPDATE, _('Updated'), 'blue'), + (ACTION_DELETE, _('Deleted'), 'red'), + ) diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index c5d332b68..f622e789c 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -1,3 +1,5 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -5,6 +7,7 @@ import django_filters from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.utils import get_data_backend_choices +from utilities.filters import ContentTypeFilter from .choices import * from .models import * @@ -13,6 +16,7 @@ __all__ = ( 'DataFileFilterSet', 'DataSourceFilterSet', 'JobFilterSet', + 'ObjectChangeFilterSet', ) @@ -126,6 +130,43 @@ class JobFilterSet(BaseFilterSet): ) +class ObjectChangeFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + time = django_filters.DateTimeFromToRangeFilter() + changed_object_type = ContentTypeFilter() + changed_object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContentType.objects.all() + ) + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=get_user_model().objects.all(), + to_field_name='username', + label=_('User name'), + ) + + class Meta: + model = ObjectChange + fields = ( + 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id', + 'related_object_type', 'related_object_id', 'object_repr', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(user_name__icontains=value) | + Q(object_repr__icontains=value) + ) + + class ConfigRevisionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 60a3acc44..c629841ae 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -7,8 +7,10 @@ from core.models import * from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from netbox.utils import get_data_backend_choices -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm -from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice +from utilities.forms.fields import ( + ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, +) from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker @@ -17,6 +19,7 @@ __all__ = ( 'DataFileFilterForm', 'DataSourceFilterForm', 'JobFilterForm', + 'ObjectChangeFilterForm', ) @@ -124,6 +127,40 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): ) +class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): + model = ObjectChange + fieldsets = ( + FieldSet('q', 'filter_id'), + FieldSet('time_before', 'time_after', name=_('Time')), + FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')), + ) + time_after = forms.DateTimeField( + required=False, + label=_('After'), + widget=DateTimePicker() + ) + time_before = forms.DateTimeField( + required=False, + label=_('Before'), + widget=DateTimePicker() + ) + action = forms.ChoiceField( + label=_('Action'), + choices=add_blank_choice(ObjectChangeActionChoices), + required=False + ) + user_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label=_('User') + ) + changed_object_type_id = ContentTypeMultipleChoiceField( + queryset=ObjectType.objects.with_feature('change_logging'), + required=False, + label=_('Object Type'), + ) + + class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( FieldSet('q', 'filter_id'), diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 64b4d0de2..82da685a5 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -6,6 +6,7 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin __all__ = ( 'DataFileFilter', 'DataSourceFilter', + 'ObjectChangeFilter', ) @@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin): @autotype_decorator(filtersets.DataSourceFilterSet) class DataSourceFilter(BaseFilterMixin): pass + + +@strawberry_django.filter(models.ObjectChange, lookups=True) +@autotype_decorator(filtersets.ObjectChangeFilterSet) +class ObjectChangeFilter(BaseFilterMixin): + pass diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py new file mode 100644 index 000000000..43f8761d1 --- /dev/null +++ b/netbox/core/graphql/mixins.py @@ -0,0 +1,24 @@ +from typing import Annotated, List + +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType + +from core.models import ObjectChange + +__all__ = ( + 'ChangelogMixin', +) + + +@strawberry.type +class ChangelogMixin: + + @strawberry_django.field + def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: + content_type = ContentType.objects.get_for_model(self) + object_changes = ObjectChange.objects.filter( + changed_object_type=content_type, + changed_object_id=self.pk + ) + return object_changes.restrict(info.context.request.user, 'view') diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 8287bfa31..09385d7c1 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -10,6 +10,7 @@ from .filters import * __all__ = ( 'DataFileType', 'DataSourceType', + 'ObjectChangeType', ) @@ -30,3 +31,12 @@ class DataFileType(BaseObjectType): class DataSourceType(NetBoxObjectType): datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]] + + +@strawberry_django.type( + models.ObjectChange, + fields='__all__', + filters=ObjectChangeFilter +) +class ObjectChangeType(BaseObjectType): + pass diff --git a/netbox/core/migrations/0011_move_objectchange.py b/netbox/core/migrations/0011_move_objectchange.py new file mode 100644 index 000000000..2b41133ec --- /dev/null +++ b/netbox/core/migrations/0011_move_objectchange.py @@ -0,0 +1,45 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0010_gfk_indexes'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='ObjectChange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('time', models.DateTimeField(auto_now_add=True, db_index=True)), + ('user_name', models.CharField(editable=False, max_length=150)), + ('request_id', models.UUIDField(db_index=True, editable=False)), + ('action', models.CharField(max_length=50)), + ('changed_object_id', models.PositiveBigIntegerField()), + ('related_object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('object_repr', models.CharField(editable=False, max_length=200)), + ('prechange_data', models.JSONField(blank=True, editable=False, null=True)), + ('postchange_data', models.JSONField(blank=True, editable=False, null=True)), + ('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'object change', + 'verbose_name_plural': 'object changes', + 'ordering': ['-time'], + 'indexes': [models.Index(fields=['changed_object_type', 'changed_object_id'], name='core_object_changed_c227ce_idx'), models.Index(fields=['related_object_type', 'related_object_id'], name='core_object_related_3375d6_idx')], + }, + ), + ], + # Table has been renamed from 'extras' app + database_operations=[], + ), + ] diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py index 2c30ce02b..db00e67aa 100644 --- a/netbox/core/models/__init__.py +++ b/netbox/core/models/__init__.py @@ -1,5 +1,6 @@ -from .config import * from .contenttypes import * +from .change_logging import * +from .config import * from .data import * from .files import * from .jobs import * diff --git a/netbox/extras/models/change_logging.py b/netbox/core/models/change_logging.py similarity index 97% rename from netbox/extras/models/change_logging.py rename to netbox/core/models/change_logging.py index 8451a0d15..1d1bbc07c 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -8,11 +8,11 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from mptt.models import MPTTModel -from core.models import ObjectType -from extras.choices import * +from core.choices import ObjectChangeActionChoices +from core.querysets import ObjectChangeQuerySet from netbox.models.features import ChangeLoggingMixin from utilities.data import shallow_compare_dict -from ..querysets import ObjectChangeQuerySet +from .contenttypes import ObjectType __all__ = ( 'ObjectChange', @@ -136,7 +136,7 @@ class ObjectChange(models.Model): return super().save(*args, **kwargs) def get_absolute_url(self): - return reverse('extras:objectchange', args=[self.pk]) + return reverse('core:objectchange', args=[self.pk]) def get_action_color(self): return ObjectChangeActionChoices.colors.get(self.action) diff --git a/netbox/core/querysets.py b/netbox/core/querysets.py new file mode 100644 index 000000000..6e646cc87 --- /dev/null +++ b/netbox/core/querysets.py @@ -0,0 +1,26 @@ +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.db.utils import ProgrammingError + +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'ObjectChangeQuerySet', +) + + +class ObjectChangeQuerySet(RestrictedQuerySet): + + def valid_models(self): + # Exclude any change records which refer to an instance of a model that's no longer installed. This + # can happen when a plugin is removed but its data remains in the database, for example. + try: + content_types = ContentType.objects.get_for_models(*apps.get_models()).values() + except ProgrammingError: + # Handle the case where the database schema has not yet been initialized + content_types = ContentType.objects.none() + + content_type_ids = set( + ct.pk for ct in content_types + ) + return self.filter(changed_object_type_id__in=content_type_ids) diff --git a/netbox/core/tables/__init__.py b/netbox/core/tables/__init__.py index 8f219afa4..cec7342f9 100644 --- a/netbox/core/tables/__init__.py +++ b/netbox/core/tables/__init__.py @@ -1,3 +1,4 @@ +from .change_logging import * from .config import * from .data import * from .jobs import * diff --git a/netbox/core/tables/change_logging.py b/netbox/core/tables/change_logging.py new file mode 100644 index 000000000..423e459e5 --- /dev/null +++ b/netbox/core/tables/change_logging.py @@ -0,0 +1,53 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ + +from core.models import ObjectChange +from netbox.tables import NetBoxTable, columns +from .template_code import * + +__all__ = ( + 'ObjectChangeTable', +) + + +class ObjectChangeTable(NetBoxTable): + time = columns.DateTimeColumn( + verbose_name=_('Time'), + timespec='minutes', + linkify=True + ) + user_name = tables.Column( + verbose_name=_('Username') + ) + full_name = tables.TemplateColumn( + accessor=tables.A('user'), + template_code=OBJECTCHANGE_FULL_NAME, + verbose_name=_('Full Name'), + orderable=False + ) + action = columns.ChoiceFieldColumn( + verbose_name=_('Action'), + ) + changed_object_type = columns.ContentTypeColumn( + verbose_name=_('Type') + ) + object_repr = tables.TemplateColumn( + accessor=tables.A('changed_object'), + template_code=OBJECTCHANGE_OBJECT, + verbose_name=_('Object'), + orderable=False + ) + request_id = tables.TemplateColumn( + template_code=OBJECTCHANGE_REQUEST_ID, + verbose_name=_('Request ID') + ) + actions = columns.ActionsColumn( + actions=() + ) + + class Meta(NetBoxTable.Meta): + model = ObjectChange + fields = ( + 'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id', + 'actions', + ) diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py new file mode 100644 index 000000000..c8f0058e7 --- /dev/null +++ b/netbox/core/tables/template_code.py @@ -0,0 +1,16 @@ +OBJECTCHANGE_FULL_NAME = """ +{% load helpers %} +{{ value.get_full_name|placeholder }} +""" + +OBJECTCHANGE_OBJECT = """ +{% if value and value.get_absolute_url %} + {{ record.object_repr }} +{% else %} + {{ record.object_repr }} +{% endif %} +""" + +OBJECTCHANGE_REQUEST_ID = """ +{{ value }} +""" diff --git a/netbox/extras/tests/test_changelog.py b/netbox/core/tests/test_changelog.py similarity index 99% rename from netbox/extras/tests/test_changelog.py rename to netbox/core/tests/test_changelog.py index aac526e0f..c58968ee8 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -3,11 +3,12 @@ from django.test import override_settings from django.urls import reverse from rest_framework import status -from core.models import ObjectType +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * -from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag +from extras.models import CustomField, CustomFieldChoiceSet, Tag from utilities.testing import APITestCase from utilities.testing.utils import create_tags, post_data from utilities.testing.views import ModelViewTestCase diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index aefb9eed0..310be1d0e 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -1,7 +1,13 @@ +import uuid from datetime import datetime, timezone +from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from utilities.testing import ChangeLoggedFilterSetTests + +from dcim.models import Site +from ipam.models import IPAddress +from users.models import User +from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests from ..choices import * from ..filtersets import * from ..models import * @@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): 'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2', ]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ObjectChangeTestCase(TestCase, BaseFilterSetTests): + queryset = ObjectChange.objects.all() + filterset = ObjectChangeFilterSet + ignore_fields = ('prechange_data', 'postchange_data') + + @classmethod + def setUpTestData(cls): + users = ( + User(username='user1'), + User(username='user2'), + User(username='user3'), + ) + User.objects.bulk_create(users) + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + ipaddress = IPAddress.objects.create(address='192.0.2.1/24') + + object_changes = ( + ObjectChange( + user=users[0], + user_name=users[0].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_CREATE, + changed_object=site, + object_repr=str(site), + postchange_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[0], + user_name=users[0].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object=site, + object_repr=str(site), + postchange_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[1], + user_name=users[1].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_DELETE, + changed_object=site, + object_repr=str(site), + postchange_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[1], + user_name=users[1].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_CREATE, + changed_object=ipaddress, + object_repr=str(ipaddress), + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ObjectChange( + user=users[2], + user_name=users[2].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object=ipaddress, + object_repr=str(ipaddress), + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ObjectChange( + user=users[2], + user_name=users[2].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_DELETE, + changed_object=ipaddress, + object_repr=str(ipaddress), + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ) + ObjectChange.objects.bulk_create(object_changes) + + def test_q(self): + params = {'q': 'Site 1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_user(self): + params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'user': ['user1', 'user2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_user_name(self): + params = {'user_name': ['user1', 'user2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_changed_object_type(self): + params = {'changed_object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index 0eeb66984..ff71c2e88 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -1,7 +1,7 @@ from django.test import TestCase from core.models import DataSource -from extras.choices import ObjectChangeActionChoices +from core.choices import ObjectChangeActionChoices from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index b7a951a0f..3c847e4ef 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,4 +1,4 @@ -import logging +import urllib.parse import uuid from datetime import datetime @@ -10,8 +10,11 @@ from django_rq.workers import get_worker from rq.job import Job as RQ_Job, JobStatus from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry +from core.choices import ObjectChangeActionChoices +from core.models import * +from dcim.models import Site +from users.models import User from utilities.testing import TestCase, ViewTestCases, create_tags -from ..models import * class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -99,6 +102,43 @@ class DataFileTestCase( DataFile.objects.bulk_create(data_files) +# TODO: Convert to StandardTestCases.Views +class ObjectChangeTestCase(TestCase): + user_permissions = ( + 'core.view_objectchange', + ) + + @classmethod + def setUpTestData(cls): + + site = Site(name='Site 1', slug='site-1') + site.save() + + # Create three ObjectChanges + user = User.objects.create_user(username='testuser2') + for i in range(1, 4): + oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE) + oc.user = user + oc.request_id = uuid.uuid4() + oc.save() + + def test_objectchange_list(self): + + url = reverse('core:objectchange_list') + params = { + "user": User.objects.first().pk, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertHttpStatus(response, 200) + + def test_objectchange(self): + + objectchange = ObjectChange.objects.first() + response = self.client.get(objectchange.get_absolute_url()) + self.assertHttpStatus(response, 200) + + class BackgroundTaskTestCase(TestCase): user_permissions = () diff --git a/netbox/core/urls.py b/netbox/core/urls.py index 59eead615..58e96d735 100644 --- a/netbox/core/urls.py +++ b/netbox/core/urls.py @@ -25,6 +25,10 @@ urlpatterns = ( path('jobs//', views.JobView.as_view(), name='job'), path('jobs//delete/', views.JobDeleteView.as_view(), name='job_delete'), + # Change logging + path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path('changelog//', include(get_model_urls('core', 'objectchange'))), + # Background Tasks path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'), path('background-queues///', views.BackgroundTaskListView.as_view(), name='background_task_list'), diff --git a/netbox/core/views.py b/netbox/core/views.py index af705c8d1..a9fb89b35 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -29,6 +29,7 @@ from netbox.config import get_config, PARAMS from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin +from utilities.data import shallow_compare_dict from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.query import count_related @@ -176,6 +177,75 @@ class JobBulkDeleteView(generic.BulkDeleteView): table = tables.JobTable +# +# Change logging +# + +class ObjectChangeListView(generic.ObjectListView): + queryset = ObjectChange.objects.valid_models() + filterset = filtersets.ObjectChangeFilterSet + filterset_form = forms.ObjectChangeFilterForm + table = tables.ObjectChangeTable + template_name = 'core/objectchange_list.html' + actions = { + 'export': {'view'}, + } + + +@register_model_view(ObjectChange) +class ObjectChangeView(generic.ObjectView): + queryset = ObjectChange.objects.valid_models() + + def get_extra_context(self, request, instance): + related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( + request_id=instance.request_id + ).exclude( + pk=instance.pk + ) + related_changes_table = tables.ObjectChangeTable( + data=related_changes[:50], + orderable=False + ) + + objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( + changed_object_type=instance.changed_object_type, + changed_object_id=instance.changed_object_id, + ) + + next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first() + prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first() + + if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change: + non_atomic_change = True + prechange_data = prev_change.postchange_data_clean + else: + non_atomic_change = False + prechange_data = instance.prechange_data_clean + + if prechange_data and instance.postchange_data: + diff_added = shallow_compare_dict( + prechange_data or dict(), + instance.postchange_data_clean or dict(), + exclude=['last_updated'], + ) + diff_removed = { + x: prechange_data.get(x) for x in diff_added + } if prechange_data else {} + else: + diff_added = None + diff_removed = None + + return { + 'diff_added': diff_added, + 'diff_removed': diff_removed, + 'next_change': next_change, + 'prev_change': prev_change, + 'related_changes_table': related_changes_table, + 'related_changes_count': related_changes.count(), + 'non_atomic_change': non_atomic_change + } + + # # Config Revisions # diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 99a9106cb..8b4613f14 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -3,14 +3,10 @@ from typing import Annotated, List, Union import strawberry import strawberry_django +from core.graphql.mixins import ChangelogMixin from dcim import models from extras.graphql.mixins import ( - ChangelogMixin, - ConfigContextMixin, - ContactsMixin, - CustomFieldsMixin, - ImageAttachmentsMixin, - TagsMixin, + ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index bd19b3184..ddd13815a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,6 @@ from .serializers_.objecttypes import * from .serializers_.attachments import * from .serializers_.bookmarks import * -from .serializers_.change_logging import * from .serializers_.customfields import * from .serializers_.customlinks import * from .serializers_.dashboard import * diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 301cc1b0a..bc68103b7 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -21,7 +21,6 @@ router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) router.register('config-templates', views.ConfigTemplateViewSet) router.register('scripts', views.ScriptViewSet, basename='script') -router.register('object-changes', views.ObjectChangeViewSet) router.register('object-types', views.ObjectTypeViewSet) app_name = 'extras-api' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 05087b2d5..34565384b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -271,20 +271,6 @@ class ScriptViewSet(ModelViewSet): return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) -# -# Change logging -# - -class ObjectChangeViewSet(ReadOnlyModelViewSet): - """ - Retrieve a list of recent changes. - """ - metadata_class = ContentTypeMetadata - queryset = ObjectChange.objects.valid_models() - serializer_class = serializers.ObjectChangeSerializer - filterset_class = filtersets.ObjectChangeFilterSet - - # # Object types # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 2c9d5836a..4a699cc3b 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -123,23 +123,6 @@ class BookmarkOrderingChoices(ChoiceSet): (ORDERING_OLDEST, _('Oldest')), ) -# -# ObjectChanges -# - - -class ObjectChangeActionChoices(ChoiceSet): - - ACTION_CREATE = 'create' - ACTION_UPDATE = 'update' - ACTION_DELETE = 'delete' - - CHOICES = ( - (ACTION_CREATE, _('Created'), 'green'), - (ACTION_UPDATE, _('Updated'), 'blue'), - (ACTION_DELETE, _('Deleted'), 'red'), - ) - # # Journal entries diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 48b44fb45..7e6ca9f84 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -128,7 +128,7 @@ DEFAULT_DASHBOARD = [ 'title': 'Change Log', 'color': 'blue', 'config': { - 'model': 'extras.objectchange', + 'model': 'core.objectchange', 'page_size': 25, } }, diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 22ce26ba9..dae3f29cf 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType @@ -6,6 +8,7 @@ from django.utils.module_loading import import_string from django.utils.translation import gettext as _ from django_rq import get_queue +from core.choices import ObjectChangeActionChoices from core.models import Job from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT @@ -13,7 +16,7 @@ from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry from utilities.serialization import serialize_object -from .choices import * +from .choices import EventRuleActionChoices from .models import EventRule logger = logging.getLogger('netbox.events_processor') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index c3ac3e6ab..12dd8042f 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -26,7 +26,6 @@ __all__ = ( 'ImageAttachmentFilterSet', 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', - 'ObjectChangeFilterSet', 'ObjectTypeFilterSet', 'SavedFilterFilterSet', 'ScriptFilterSet', @@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): return queryset.exclude(local_context_data__isnull=value) -class ObjectChangeFilterSet(BaseFilterSet): - q = django_filters.CharFilter( - method='search', - label=_('Search'), - ) - time = django_filters.DateTimeFromToRangeFilter() - changed_object_type = ContentTypeFilter() - changed_object_type_id = django_filters.ModelMultipleChoiceFilter( - queryset=ContentType.objects.all() - ) - user_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), - label=_('User (ID)'), - ) - user = django_filters.ModelMultipleChoiceFilter( - field_name='user__username', - queryset=get_user_model().objects.all(), - to_field_name='username', - label=_('User name'), - ) - - class Meta: - model = ObjectChange - fields = ( - 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id', - 'related_object_type', 'related_object_id', 'object_repr', - ) - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(user_name__icontains=value) | - Q(object_repr__icontains=value) - ) - - # # ContentTypes # diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index e6b001f2c..658aae83b 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -14,7 +14,7 @@ from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import APISelectMultiple, DateTimePicker +from utilities.forms.widgets import DateTimePicker from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( @@ -28,7 +28,6 @@ __all__ = ( 'ImageAttachmentFilterForm', 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', - 'ObjectChangeFilterForm', 'SavedFilterFilterForm', 'TagFilterForm', 'WebhookFilterForm', @@ -475,37 +474,3 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): required=False ) tag = TagFilterField(model) - - -class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): - model = ObjectChange - fieldsets = ( - FieldSet('q', 'filter_id'), - FieldSet('time_before', 'time_after', name=_('Time')), - FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')), - ) - time_after = forms.DateTimeField( - required=False, - label=_('After'), - widget=DateTimePicker() - ) - time_before = forms.DateTimeField( - required=False, - label=_('Before'), - widget=DateTimePicker() - ) - action = forms.ChoiceField( - label=_('Action'), - choices=add_blank_choice(ObjectChangeActionChoices), - required=False - ) - user_id = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), - required=False, - label=_('User') - ) - changed_object_type_id = ContentTypeMultipleChoiceField( - queryset=ObjectType.objects.with_feature('change_logging'), - required=False, - label=_('Object Type'), - ) diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index af3a93588..7451eef8a 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -13,7 +13,6 @@ __all__ = ( 'ExportTemplateFilter', 'ImageAttachmentFilter', 'JournalEntryFilter', - 'ObjectChangeFilter', 'SavedFilterFilter', 'TagFilter', 'WebhookFilter', @@ -68,12 +67,6 @@ class JournalEntryFilter(BaseFilterMixin): pass -@strawberry_django.filter(models.ObjectChange, lookups=True) -@autotype_decorator(filtersets.ObjectChangeFilterSet) -class ObjectChangeFilter(BaseFilterMixin): - pass - - @strawberry_django.filter(models.SavedFilter, lookups=True) @autotype_decorator(filtersets.SavedFilterFilterSet) class SavedFilterFilter(BaseFilterMixin): diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 456c6daa5..542bbcc85 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -2,12 +2,8 @@ from typing import TYPE_CHECKING, Annotated, List import strawberry import strawberry_django -from django.contrib.contenttypes.models import ContentType - -from extras.models import ObjectChange __all__ = ( - 'ChangelogMixin', 'ConfigContextMixin', 'ContactsMixin', 'CustomFieldsMixin', @@ -17,23 +13,10 @@ __all__ = ( ) if TYPE_CHECKING: - from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType + from .types import ImageAttachmentType, JournalEntryType, TagType from tenancy.graphql.types import ContactAssignmentType -@strawberry.type -class ChangelogMixin: - - @strawberry_django.field - def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: - content_type = ContentType.objects.get_for_model(self) - object_changes = ObjectChange.objects.filter( - changed_object_type=content_type, - changed_object_id=self.pk - ) - return object_changes.restrict(info.context.request.user, 'view') - - @strawberry.type class ConfigContextMixin: diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 6bb7ce411..1f3bfcdb9 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -18,7 +18,6 @@ __all__ = ( 'ExportTemplateType', 'ImageAttachmentType', 'JournalEntryType', - 'ObjectChangeType', 'SavedFilterType', 'TagType', 'WebhookType', @@ -123,15 +122,6 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( - models.ObjectChange, - fields='__all__', - filters=ObjectChangeFilter -) -class ObjectChangeType(BaseObjectType): - pass - - @strawberry_django.type( models.SavedFilter, exclude=['content_types',], diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index 467518fef..2ba0cbd72 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -9,8 +9,7 @@ from django.db import DEFAULT_DB_ALIAS from django.utils import timezone from packaging import version -from core.models import Job -from extras.models import ObjectChange +from core.models import Job, ObjectChange from netbox.config import Config diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index ef1bd5141..dbfbb40d9 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -10,9 +10,9 @@ from django.db import transaction from core.choices import JobStatusChoices from core.models import Job -from extras.context_managers import event_tracking from extras.scripts import get_module_and_script from extras.signals import clear_events +from netbox.context_managers import event_tracking from utilities.exceptions import AbortTransaction from utilities.request import NetBoxFakeRequest diff --git a/netbox/extras/migrations/0116_move_objectchange.py b/netbox/extras/migrations/0116_move_objectchange.py new file mode 100644 index 000000000..c546a1aa4 --- /dev/null +++ b/netbox/extras/migrations/0116_move_objectchange.py @@ -0,0 +1,57 @@ +from django.db import migrations + + +def update_content_types(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + + # Delete the new ContentTypes effected by the new model in the core app + ContentType.objects.filter(app_label='core', model='objectchange').delete() + + # Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any + # foreign key references are preserved + ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core') + + +def update_dashboard_widgets(apps, schema_editor): + Dashboard = apps.get_model('extras', 'Dashboard') + + for dashboard in Dashboard.objects.all(): + for key, widget in dashboard.config.items(): + if getattr(widget['config'], 'model') == 'extras.objectchange': + widget['config']['model'] = 'core.objectchange' + elif models := widget['config'].get('models'): + models = list(map(lambda x: x.replace('extras.objectchange', 'core.objectchange'), models)) + dashboard.config[key]['config']['models'] = models + dashboard.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0115_convert_dashboard_widgets'), + ('core', '0011_move_objectchange'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel( + name='ObjectChange', + ), + ], + database_operations=[ + migrations.AlterModelTable( + name='ObjectChange', + table='core_objectchange', + ), + ], + ), + migrations.RunPython( + code=update_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=update_dashboard_widgets, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index ebed3b1fb..0413d1b91 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,4 +1,3 @@ -from .change_logging import * from .configs import * from .customfields import * from .dashboard import * diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 49249eaa0..cf4395943 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,6 @@ from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder @@ -23,9 +22,9 @@ from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) from utilities.html import clean_html +from utilities.jinja2 import render_jinja2 from utilities.querydict import dict_to_querydict from utilities.querysets import RestrictedQuerySet -from utilities.jinja2 import render_jinja2 __all__ = ( 'Bookmark', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index b3bdc0a3e..3ee9d73e8 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,8 +1,5 @@ -from django.apps import apps -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.aggregates import JSONBAgg from django.db.models import OuterRef, Subquery, Q -from django.db.utils import ProgrammingError from extras.models.tags import TaggedItem from utilities.query_functions import EmptyGroupByJSONBAgg @@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) return base_query - - -class ObjectChangeQuerySet(RestrictedQuerySet): - - def valid_models(self): - # Exclude any change records which refer to an instance of a model that's no longer installed. This - # can happen when a plugin is removed but its data remains in the database, for example. - try: - content_types = ContentType.objects.get_for_models(*apps.get_models()).values() - except ProgrammingError: - # Handle the case where the database schema has not yet been initialized - content_types = ContentType.objects.none() - - content_type_ids = set( - ct.pk for ct in content_types - ) - return self.filter(changed_object_type_id__in=content_type_ids) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 0e74c3f0d..b4a1d6de1 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -21,11 +21,11 @@ from extras.models import ScriptModule, Script as ScriptModel from extras.signals import clear_events from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator +from netbox.context_managers import event_tracking from utilities.exceptions import AbortScript, AbortTransaction from utilities.forms import add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import DatePicker, DateTimePicker -from .context_managers import event_tracking from .forms import ScriptForm from .utils import is_report diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 9d439ace9..53ec39cac 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -9,7 +9,8 @@ from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates -from core.models import ObjectType +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.events import process_event_rules @@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin from netbox.signals import post_clean from utilities.exceptions import AbortRequest -from .choices import ObjectChangeActionChoices from .events import enqueue_object, get_snapshots, serialize_for_event -from .models import CustomField, ObjectChange, TaggedItem +from .models import CustomField, TaggedItem from .validators import CustomValidator diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 8c78ad0de..d04a67e07 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -19,7 +19,6 @@ __all__ = ( 'ExportTemplateTable', 'ImageAttachmentTable', 'JournalEntryTable', - 'ObjectChangeTable', 'SavedFilterTable', 'ReportResultsTable', 'ScriptResultsTable', @@ -451,49 +450,6 @@ class ConfigTemplateTable(NetBoxTable): ) -class ObjectChangeTable(NetBoxTable): - time = columns.DateTimeColumn( - verbose_name=_('Time'), - timespec='minutes', - linkify=True - ) - user_name = tables.Column( - verbose_name=_('Username') - ) - full_name = tables.TemplateColumn( - accessor=tables.A('user'), - template_code=OBJECTCHANGE_FULL_NAME, - verbose_name=_('Full Name'), - orderable=False - ) - action = columns.ChoiceFieldColumn( - verbose_name=_('Action'), - ) - changed_object_type = columns.ContentTypeColumn( - verbose_name=_('Type') - ) - object_repr = tables.TemplateColumn( - accessor=tables.A('changed_object'), - template_code=OBJECTCHANGE_OBJECT, - verbose_name=_('Object'), - orderable=False - ) - request_id = tables.TemplateColumn( - template_code=OBJECTCHANGE_REQUEST_ID, - verbose_name=_('Request ID') - ) - actions = columns.ActionsColumn( - actions=() - ) - - class Meta(NetBoxTable.Meta): - model = ObjectChange - fields = ( - 'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id', - 'actions', - ) - - class JournalEntryTable(NetBoxTable): created = columns.DateTimeColumn( verbose_name=_('Created'), diff --git a/netbox/extras/tables/template_code.py b/netbox/extras/tables/template_code.py index 2c6248469..fe1a5685d 100644 --- a/netbox/extras/tables/template_code.py +++ b/netbox/extras/tables/template_code.py @@ -6,20 +6,3 @@ CONFIGCONTEXT_ACTIONS = """ {% endif %} """ - -OBJECTCHANGE_FULL_NAME = """ -{% load helpers %} -{{ value.get_full_name|placeholder }} -""" - -OBJECTCHANGE_OBJECT = """ -{% if value and value.get_absolute_url %} - {{ record.object_repr }} -{% else %} - {{ record.object_repr }} -{% endif %} -""" - -OBJECTCHANGE_REQUEST_ID = """ -{{ value }} -""" diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index a1dd8b48e..39b896616 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -9,14 +9,15 @@ from django.urls import reverse from requests import Session from rest_framework import status +from core.choices import ObjectChangeActionChoices from core.models import ObjectType from dcim.choices import SiteStatusChoices from dcim.models import Site -from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices -from extras.context_managers import event_tracking +from extras.choices import EventRuleActionChoices from extras.events import enqueue_object, flush_events, serialize_for_event from extras.models import EventRule, Tag, Webhook from extras.webhooks import generate_signature, send_webhook +from netbox.context_managers import event_tracking from utilities.testing import APITestCase diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index b68c02efc..5c737f7cf 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -6,15 +6,14 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import Provider -from core.choices import ManagedFileRootPathChoices -from core.models import ObjectType +from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from dcim.filtersets import SiteFilterSet from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from dcim.models import Location from extras.choices import * from extras.filtersets import * from extras.models import * -from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -1280,102 +1279,6 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): ) -class ObjectChangeTestCase(TestCase, BaseFilterSetTests): - queryset = ObjectChange.objects.all() - filterset = ObjectChangeFilterSet - ignore_fields = ('prechange_data', 'postchange_data') - - @classmethod - def setUpTestData(cls): - users = ( - User(username='user1'), - User(username='user2'), - User(username='user3'), - ) - User.objects.bulk_create(users) - - site = Site.objects.create(name='Test Site 1', slug='test-site-1') - ipaddress = IPAddress.objects.create(address='192.0.2.1/24') - - object_changes = ( - ObjectChange( - user=users[0], - user_name=users[0].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_CREATE, - changed_object=site, - object_repr=str(site), - postchange_data={'name': site.name, 'slug': site.slug} - ), - ObjectChange( - user=users[0], - user_name=users[0].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_UPDATE, - changed_object=site, - object_repr=str(site), - postchange_data={'name': site.name, 'slug': site.slug} - ), - ObjectChange( - user=users[1], - user_name=users[1].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_DELETE, - changed_object=site, - object_repr=str(site), - postchange_data={'name': site.name, 'slug': site.slug} - ), - ObjectChange( - user=users[1], - user_name=users[1].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_CREATE, - changed_object=ipaddress, - object_repr=str(ipaddress), - postchange_data={'address': ipaddress.address, 'status': ipaddress.status} - ), - ObjectChange( - user=users[2], - user_name=users[2].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_UPDATE, - changed_object=ipaddress, - object_repr=str(ipaddress), - postchange_data={'address': ipaddress.address, 'status': ipaddress.status} - ), - ObjectChange( - user=users[2], - user_name=users[2].username, - request_id=uuid.uuid4(), - action=ObjectChangeActionChoices.ACTION_DELETE, - changed_object=ipaddress, - object_repr=str(ipaddress), - postchange_data={'address': ipaddress.address, 'status': ipaddress.status} - ), - ) - ObjectChange.objects.bulk_create(object_changes) - - def test_q(self): - params = {'q': 'Site 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - def test_user(self): - params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'user': ['user1', 'user2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - - def test_user_name(self): - params = {'user_name': ['user1', 'user2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - - def test_changed_object_type(self): - params = {'changed_object_type': 'dcim.site'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - class ChangeLoggedFilterSetTestCase(TestCase): """ Evaluate base ChangeLoggedFilterSet filters using the Site model. diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index fd478acd4..cbede195b 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,6 +1,3 @@ -import urllib.parse -import uuid - from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -567,43 +564,6 @@ class ConfigTemplateTestCase( } -# TODO: Convert to StandardTestCases.Views -class ObjectChangeTestCase(TestCase): - user_permissions = ( - 'extras.view_objectchange', - ) - - @classmethod - def setUpTestData(cls): - - site = Site(name='Site 1', slug='site-1') - site.save() - - # Create three ObjectChanges - user = User.objects.create_user(username='testuser2') - for i in range(1, 4): - oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE) - oc.user = user - oc.request_id = uuid.uuid4() - oc.save() - - def test_objectchange_list(self): - - url = reverse('extras:objectchange_list') - params = { - "user": User.objects.first().pk, - } - - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_objectchange(self): - - objectchange = ObjectChange.objects.first() - response = self.client.get(objectchange.get_absolute_url()) - self.assertHttpStatus(response, 200) - - class JournalEntryTestCase( # ViewTestCases.GetObjectViewTestCase, ViewTestCases.CreateObjectViewTestCase, diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 14e74c5ca..f2e11e71e 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -106,10 +106,6 @@ urlpatterns = [ path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), - # Change logging - path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path('changelog//', include(get_model_urls('extras', 'objectchange'))), - # User dashboard path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'), path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 82f519c00..efbf2c73a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -19,7 +19,6 @@ from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from netbox.views.generic.mixins import TableMixin -from utilities.data import shallow_compare_dict from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import htmx_partial from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -683,75 +682,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): queryset = ConfigTemplate.objects.all() -# -# Change logging -# - -class ObjectChangeListView(generic.ObjectListView): - queryset = ObjectChange.objects.valid_models() - filterset = filtersets.ObjectChangeFilterSet - filterset_form = forms.ObjectChangeFilterForm - table = tables.ObjectChangeTable - template_name = 'extras/objectchange_list.html' - actions = { - 'export': {'view'}, - } - - -@register_model_view(ObjectChange) -class ObjectChangeView(generic.ObjectView): - queryset = ObjectChange.objects.valid_models() - - def get_extra_context(self, request, instance): - related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( - request_id=instance.request_id - ).exclude( - pk=instance.pk - ) - related_changes_table = tables.ObjectChangeTable( - data=related_changes[:50], - orderable=False - ) - - objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( - changed_object_type=instance.changed_object_type, - changed_object_id=instance.changed_object_id, - ) - - next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first() - prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first() - - if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change: - non_atomic_change = True - prechange_data = prev_change.postchange_data_clean - else: - non_atomic_change = False - prechange_data = instance.prechange_data_clean - - if prechange_data and instance.postchange_data: - diff_added = shallow_compare_dict( - prechange_data or dict(), - instance.postchange_data_clean or dict(), - exclude=['last_updated'], - ) - diff_removed = { - x: prechange_data.get(x) for x in diff_added - } if prechange_data else {} - else: - diff_added = None - diff_removed = None - - return { - 'diff_added': diff_added, - 'diff_removed': diff_removed, - 'next_change': next_change, - 'prev_change': prev_change, - 'related_changes_table': related_changes_table, - 'related_changes_count': related_changes.count(), - 'non_atomic_change': non_atomic_change - } - - # # Image attachments # diff --git a/netbox/extras/context_managers.py b/netbox/netbox/context_managers.py similarity index 94% rename from netbox/extras/context_managers.py rename to netbox/netbox/context_managers.py index e72cb8cc2..ca434df82 100644 --- a/netbox/extras/context_managers.py +++ b/netbox/netbox/context_managers.py @@ -1,7 +1,7 @@ from contextlib import contextmanager from netbox.context import current_request, events_queue -from .events import flush_events +from extras.events import flush_events @contextmanager diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 7f07cfbfb..ac43fe57f 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -7,9 +7,11 @@ from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field from django.utils.translation import gettext as _ -from extras.choices import CustomFieldFilterLogicChoices, ObjectChangeActionChoices +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange +from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter -from extras.models import CustomField, ObjectChange, SavedFilter +from extras.models import CustomField, SavedFilter from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 64aa3617a..a4fc99080 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,17 +1,10 @@ -from typing import Annotated, List - import strawberry -from strawberry import auto import strawberry_django - -from core.models import ObjectType as ObjectType_ from django.contrib.contenttypes.models import ContentType -from extras.graphql.mixins import ( - ChangelogMixin, - CustomFieldsMixin, - JournalEntriesMixin, - TagsMixin, -) + +from core.graphql.mixins import ChangelogMixin +from core.models import ObjectType as ObjectType_ +from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin __all__ = ( 'BaseObjectType', diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 6e7da9ab0..58c70451c 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -10,8 +10,8 @@ from django.db import connection, ProgrammingError from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect -from extras.context_managers import event_tracking from netbox.config import clear_config, get_config +from netbox.context_managers import event_tracking from netbox.views import handler_500 from utilities.api import is_api_request from utilities.error_handlers import handle_rest_api_exception diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 000e717a4..ac6be67d9 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,7 +9,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager -from core.choices import JobStatusChoices +from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.models import ObjectType from extras.choices import * from extras.utils import is_taggable @@ -90,7 +90,8 @@ class ChangeLoggingMixin(models.Model): Return a new ObjectChange representing a change made to this object. This will typically be called automatically by ChangeLoggingMiddleware. """ - from extras.models import ObjectChange + # TODO: Fix circular import + from core.models import ObjectChange exclude = [] if get_config().CHANGELOG_SKIP_EMPTY_CHANGES: diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 002dfd98a..6db7ac14c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu( label=_('Logging'), items=( get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), - get_model_item('extras', 'objectchange', _('Change Log'), actions=[]), + get_model_item('core', 'objectchange', _('Change Log'), actions=[]), ), ), ), diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 95b7b5712..9d898be2f 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -6,10 +6,11 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ from django.views.generic import View -from core.models import Job -from core.tables import JobTable -from extras import forms, tables -from extras.models import * +from core.models import Job, ObjectChange +from core.tables import JobTable, ObjectChangeTable +from extras.forms import JournalEntryForm +from extras.models import JournalEntry +from extras.tables import JournalEntryTable from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin, ViewTab from .base import BaseMultiObjectView @@ -56,7 +57,7 @@ class ObjectChangeLogView(View): Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(related_object_type=content_type, related_object_id=obj.pk) ) - objectchanges_table = tables.ObjectChangeTable( + objectchanges_table = ObjectChangeTable( data=objectchanges, orderable=False, user=request.user @@ -108,13 +109,13 @@ class ObjectJournalView(View): assigned_object_type=content_type, assigned_object_id=obj.pk ) - journalentry_table = tables.JournalEntryTable(journalentries, user=request.user) + journalentry_table = JournalEntryTable(journalentries, user=request.user) journalentry_table.configure(request) journalentry_table.columns.hide('assigned_object_type') journalentry_table.columns.hide('assigned_object') if request.user.has_perm('extras.add_journalentry'): - form = forms.JournalEntryForm( + form = JournalEntryForm( initial={ 'assigned_object_type': ContentType.objects.get_for_model(obj), 'assigned_object_id': obj.pk diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/core/objectchange.html similarity index 90% rename from netbox/templates/extras/objectchange.html rename to netbox/templates/core/objectchange.html index ffd6e77fa..6613adb22 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/core/objectchange.html @@ -6,7 +6,7 @@ {% block title %}{{ object }}{% endblock %} {% block breadcrumbs %} - + {% if object.related_object and object.related_object.get_absolute_url %} {% elif object.changed_object and object.changed_object.get_absolute_url %} @@ -78,10 +78,10 @@
{% trans "Difference" %} @@ -119,7 +119,7 @@ {% endspaceless %} {% elif non_atomic_change %} - {% trans "Warning: Comparing non-atomic change to previous change record" %} ({{ prev_change.pk }}) + {% trans "Warning: Comparing non-atomic change to previous change record" %} ({{ prev_change.pk }}) {% else %} {% trans "None" %} {% endif %} @@ -158,7 +158,7 @@ {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %} {% if related_changes_count > related_changes_table.rows|length %}
- + {% blocktrans trimmed with count=related_changes_count|add:"1" %} See All {{ count }} Changes {% endblocktrans %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/core/objectchange_list.html similarity index 100% rename from netbox/templates/extras/objectchange_list.html rename to netbox/templates/core/objectchange_list.html diff --git a/netbox/users/views.py b/netbox/users/views.py index 40d69e98c..b2f9a8d04 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,7 +1,7 @@ from django.db.models import Count -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable +from core.models import ObjectChange +from core.tables import ObjectChangeTable from netbox.views import generic from utilities.views import register_model_view from . import filtersets, forms, tables diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 62ac817e2..a0d4be848 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,29 +1,26 @@ import inspect import json -import strawberry_django +import strawberry_django from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.urls import reverse from django.test import override_settings +from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from strawberry.lazy_type import LazyType +from strawberry.type import StrawberryList, StrawberryOptional +from strawberry.union import StrawberryUnion -from core.models import ObjectType -from extras.choices import ObjectChangeActionChoices -from extras.models import ObjectChange +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType +from ipam.graphql.types import IPAddressFamilyType from users.models import ObjectPermission, Token from utilities.api import get_graphql_type_for_model from .base import ModelTestCase from .utils import disable_warnings -from ipam.graphql.types import IPAddressFamilyType -from strawberry.field import StrawberryField -from strawberry.lazy_type import LazyType -from strawberry.type import StrawberryList, StrawberryOptional -from strawberry.union import StrawberryUnion - __all__ = ( 'APITestCase', 'APIViewTestCases', diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 6d4ca00df..18c767bd0 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -8,9 +8,8 @@ from django.test import override_settings from django.urls import reverse from django.utils.translation import gettext as _ -from core.models import ObjectType -from extras.choices import ObjectChangeActionChoices -from extras.models import ObjectChange +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin from users.models import ObjectPermission From 6abad9c20c6d3871ae4ead5251ae6fcc35cb14cc Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 14 Jun 2024 08:32:24 -0700 Subject: [PATCH 16/54] 16586 add .python-version to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 88faab27c..e04e44a30 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ netbox.pid .idea .coverage .vscode +.python-version From e12edd7420937138c9a809ef77a1031014a2f1fa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 08:36:03 -0400 Subject: [PATCH 17/54] #16388: Fix migration bug --- netbox/extras/migrations/0116_move_objectchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/migrations/0116_move_objectchange.py b/netbox/extras/migrations/0116_move_objectchange.py index c546a1aa4..5ac134004 100644 --- a/netbox/extras/migrations/0116_move_objectchange.py +++ b/netbox/extras/migrations/0116_move_objectchange.py @@ -17,7 +17,7 @@ def update_dashboard_widgets(apps, schema_editor): for dashboard in Dashboard.objects.all(): for key, widget in dashboard.config.items(): - if getattr(widget['config'], 'model') == 'extras.objectchange': + if widget['config'].get('model') == 'extras.objectchange': widget['config']['model'] = 'core.objectchange' elif models := widget['config'].get('models'): models = list(map(lambda x: x.replace('extras.objectchange', 'core.objectchange'), models)) From 91dcecbd077d1af3c886c4d34a45a3c823b3f350 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 17 Jun 2024 06:19:49 -0700 Subject: [PATCH 18/54] 15106 Add Length Field to Wireless Link (#16528) * 15106 add wireles link length * 15106 add wireles link length * 15106 add wireless link length * 15106 add tests * 15106 rename length -> distance * 15106 rename length -> distance * 15106 review comments * 15106 review comments * 15106 fix form * 15106 length -> distance --- docs/models/wireless/wirelesslink.md | 4 +++ netbox/dcim/forms/model_forms.py | 5 --- netbox/dcim/svg/cables.py | 2 ++ netbox/dcim/tables/cables.py | 2 +- netbox/templates/wireless/wirelesslink.html | 10 ++++++ .../api/serializers_/wirelesslinks.py | 4 ++- netbox/wireless/choices.py | 18 ++++++++++ netbox/wireless/filtersets.py | 2 +- netbox/wireless/forms/bulk_edit.py | 16 +++++++-- netbox/wireless/forms/bulk_import.py | 10 ++++-- netbox/wireless/forms/filtersets.py | 11 +++++- netbox/wireless/forms/model_forms.py | 6 ++-- .../migrations/0009_wirelesslink_distance.py | 28 +++++++++++++++ netbox/wireless/models.py | 35 +++++++++++++++++++ netbox/wireless/tables/template_code.py | 4 +++ netbox/wireless/tables/wirelesslink.py | 8 ++++- netbox/wireless/tests/test_api.py | 2 ++ netbox/wireless/tests/test_filtersets.py | 14 ++++++++ netbox/wireless/tests/test_views.py | 4 +++ 19 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 netbox/wireless/migrations/0009_wirelesslink_distance.py create mode 100644 netbox/wireless/tables/template_code.py diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md index c9b331570..e670b69ec 100644 --- a/docs/models/wireless/wirelesslink.md +++ b/docs/models/wireless/wirelesslink.md @@ -40,3 +40,7 @@ The security cipher used to apply wireless authentication. Options include: ### Pre-Shared Key The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. + +### Distance + +The numeric distance of the link, including a unit designation (e.g. 100 meters or 25 feet). diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d5cc0e856..c1ed1049f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -656,11 +656,6 @@ class CableForm(TenancyForm, NetBoxModelForm): 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] - error_messages = { - 'length': { - 'max_value': _('Maximum length is 32767 (any unit)') - } - } class PowerPanelForm(NetBoxModelForm): diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9ce5d967b..959414d75 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -393,6 +393,8 @@ class CableTraceSVG: labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] if cable.ssid: description.append(f"{cable.ssid}") + if cable.distance and cable.distance_unit: + description.append(f"{cable.distance} {cable.get_distance_unit_display()}") near = [term for term in near_terminations if term.object == cable.interface_a] far = [term for term in far_terminations if term.object == cable.interface_b] if not (near and far): diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 0fb2f7d61..296bfd46c 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -109,7 +109,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn() length = columns.TemplateColumn( template_code=CABLE_LENGTH, - order_by=('_abs_length', 'length_unit') + order_by=('_abs_length') ) color = columns.ColorColumn() comments = columns.MarkdownColumn() diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 3237c9ab8..b31c3132c 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -34,6 +34,16 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Distance" %} + + {% if object.distance is not None %} + {{ object.distance|floatformat }} {{ object.get_distance_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + +
{% include 'inc/panels/tags.html' %} diff --git a/netbox/wireless/api/serializers_/wirelesslinks.py b/netbox/wireless/api/serializers_/wirelesslinks.py index 3a7f88856..7c8138f9b 100644 --- a/netbox/wireless/api/serializers_/wirelesslinks.py +++ b/netbox/wireless/api/serializers_/wirelesslinks.py @@ -21,11 +21,13 @@ class WirelessLinkSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) + distance_unit = ChoiceField(choices=WirelessLinkDistanceUnitChoices, allow_blank=True, required=False, allow_null=True) class Meta: model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', - 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'ssid', 'description') diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 710cd3a8d..f17ea584d 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -481,3 +481,21 @@ class WirelessAuthCipherChoices(ChoiceSet): (CIPHER_TKIP, 'TKIP'), (CIPHER_AES, 'AES'), ) + + +class WirelessLinkDistanceUnitChoices(ChoiceSet): + + # Metric + UNIT_KILOMETER = 'km' + UNIT_METER = 'm' + + # Imperial + UNIT_MILE = 'mi' + UNIT_FOOT = 'ft' + + CHOICES = ( + (UNIT_KILOMETER, _('Kilometers')), + (UNIT_METER, _('Meters')), + (UNIT_MILE, _('Miles')), + (UNIT_FOOT, _('Feet')), + ) diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index da66df144..9f60388ce 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -105,7 +105,7 @@ class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = WirelessLink - fields = ('id', 'ssid', 'auth_psk', 'description') + fields = ('id', 'ssid', 'auth_psk', 'distance', 'distance_unit', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 84916e8d9..64a9bfa98 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -125,6 +125,17 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False, label=_('Pre-shared key') ) + distance = forms.DecimalField( + label=_('Distance'), + min_value=0, + required=False + ) + distance_unit = forms.ChoiceField( + label=_('Distance unit'), + choices=add_blank_choice(WirelessLinkDistanceUnitChoices), + required=False, + initial='' + ) description = forms.CharField( label=_('Description'), max_length=200, @@ -135,8 +146,9 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLink fieldsets = ( FieldSet('ssid', 'status', 'tenant', 'description'), - FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')) + FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), + FieldSet('distance', 'distance_unit', name=_('Attributes')), ) nullable_fields = ( - 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'comments', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 38bc37360..878afd5c8 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -112,10 +112,16 @@ class WirelessLinkImportForm(NetBoxModelImportForm): required=False, help_text=_('Authentication cipher') ) + distance_unit = CSVChoiceField( + label=_('Distance unit'), + choices=WirelessLinkDistanceUnitChoices, + required=False, + help_text=_('Distance unit') + ) class Meta: model = WirelessLink fields = ( - 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', 'tags', + 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', + 'distance', 'distance_unit', 'description', 'comments', 'tags', ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 2458d7b48..f87cadfb9 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -71,7 +71,7 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('ssid', 'status', name=_('Attributes')), + FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -98,4 +98,13 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Pre-shared key'), required=False ) + distance = forms.DecimalField( + label=_('Distance'), + required=False, + ) + distance_unit = forms.ChoiceField( + label=_('Distance unit'), + choices=add_blank_choice(WirelessLinkDistanceUnitChoices), + required=False + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 05debf8bf..6d46373ac 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -159,7 +159,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): fieldsets = ( FieldSet('site_a', 'location_a', 'device_a', 'interface_a', name=_('Side A')), FieldSet('site_b', 'location_b', 'device_b', 'interface_b', name=_('Side B')), - FieldSet('status', 'ssid', 'description', 'tags', name=_('Link')), + FieldSet('status', 'ssid', 'distance', 'distance_unit', 'description', 'tags', name=_('Link')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) @@ -168,8 +168,8 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', + 'distance', 'distance_unit', 'description', 'comments', 'tags', ] widgets = { 'auth_psk': PasswordInput( diff --git a/netbox/wireless/migrations/0009_wirelesslink_distance.py b/netbox/wireless/migrations/0009_wirelesslink_distance.py new file mode 100644 index 000000000..6a778ef00 --- /dev/null +++ b/netbox/wireless/migrations/0009_wirelesslink_distance.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2024-06-12 18:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_squashed_0008'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslink', + name='_abs_distance', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True), + ), + migrations.AddField( + model_name='wirelesslink', + name='distance', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='wirelesslink', + name='distance_unit', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 0b114f85f..4214ac29d 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from netbox.models import NestedGroupModel, PrimaryModel +from utilities.conversion import to_meters from .choices import * from .constants import * @@ -160,6 +161,26 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED ) + distance = models.DecimalField( + verbose_name=_('distance'), + max_digits=8, + decimal_places=2, + blank=True, + null=True + ) + distance_unit = models.CharField( + verbose_name=_('distance unit'), + max_length=50, + choices=WirelessLinkDistanceUnitChoices, + blank=True, + ) + # Stores the normalized distance (in meters) for database ordering + _abs_distance = models.DecimalField( + max_digits=10, + decimal_places=4, + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -208,6 +229,11 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): return LinkStatusChoices.colors.get(self.status) def clean(self): + super().clean() + + # Validate distance and distance_unit + if self.distance is not None and not self.distance_unit: + raise ValidationError(_("Must specify a unit when setting a wireless distance")) # Validate interface types if self.interface_a.type not in WIRELESS_IFACE_TYPES: @@ -224,6 +250,15 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): }) def save(self, *args, **kwargs): + # Store the given distance (if any) in meters for use in database ordering + if self.distance is not None and self.distance_unit: + self._abs_distance = to_meters(self.distance, self.distance_unit) + else: + self._abs_distance = None + + # Clear distance_unit if no distance is defined + if self.distance is None: + self.distance_unit = '' # Store the parent Device for the A and B interfaces self._interface_a_device = self.interface_a.device diff --git a/netbox/wireless/tables/template_code.py b/netbox/wireless/tables/template_code.py new file mode 100644 index 000000000..03c893c1f --- /dev/null +++ b/netbox/wireless/tables/template_code.py @@ -0,0 +1,4 @@ +WIRELESS_LINK_DISTANCE = """ +{% load helpers %} +{% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %} +""" diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py index 7c3b3306b..9d3a50848 100644 --- a/netbox/wireless/tables/wirelesslink.py +++ b/netbox/wireless/tables/wirelesslink.py @@ -4,6 +4,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns from tenancy.tables import TenancyColumnsMixin from wireless.models import * +from .template_code import WIRELESS_LINK_DISTANCE __all__ = ( 'WirelessLinkTable', @@ -36,6 +37,10 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Interface B'), linkify=True ) + distance = columns.TemplateColumn( + template_code=WIRELESS_LINK_DISTANCE, + order_by=('_abs_distance') + ) tags = columns.TagColumn( url_name='wireless:wirelesslink_list' ) @@ -44,7 +49,8 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', - 'tenant_group', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'tenant_group', 'distance', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index f67d42439..cd26debf7 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -113,6 +113,8 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'ssid', 'url'] bulk_update_data = { 'status': 'planned', + 'distance': 100, + 'distance_unit': 'm', } @classmethod diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 72264a158..46eec4d7b 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -260,6 +260,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1', tenant=tenants[0], + distance=10, + distance_unit=WirelessLinkDistanceUnitChoices.UNIT_FOOT, description='foobar1' ).save() WirelessLink( @@ -271,6 +273,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2', tenant=tenants[1], + distance=20, + distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER, description='foobar2' ).save() WirelessLink( @@ -281,6 +285,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3', + distance=30, + distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER, tenant=tenants[2], ).save() WirelessLink( @@ -313,6 +319,14 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'auth_psk': ['PSK1', 'PSK2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_distance(self): + params = {'distance': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_distance_unit(self): + params = {'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 62c3b451f..055edf73c 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -160,6 +160,8 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'interface_a': interfaces[6].pk, 'interface_b': interfaces[7].pk, 'status': LinkStatusChoices.STATUS_PLANNED, + 'distance': 100, + 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT, 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } @@ -180,4 +182,6 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'status': LinkStatusChoices.STATUS_PLANNED, + 'distance': 50, + 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_METER, } From 95593495413b7821cbb8fab4603f74c78d9c2354 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 10:58:47 -0400 Subject: [PATCH 19/54] Fixes #16450: Rack unit filter should be case-insensitive --- netbox/dcim/api/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d6ddd466b..be7a9c306 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet): ) # Enable filtering rack units by ID - q = data['q'] - if q: - elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])] + if q := data['q']: + q = q.lower() + elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()] page = self.paginate_queryset(elevation) if page is not None: From 6f35a2ac2b42a43329dd3ebed4d07f257361fbc5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 10:32:47 -0400 Subject: [PATCH 20/54] Fixes #16452: Fix sizing of buttons within object attribute panels --- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/site.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 12ba4a8d4..50136f7a9 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -28,7 +28,7 @@ {% trans "Rack" %} - + {% if object.rack %} {{ object.rack|linkify }} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1ad0a75ae..ca0937bed 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -73,7 +73,7 @@ {% trans "Physical Address" %} - + {% if object.physical_address %} {{ object.physical_address|linebreaksbr }} {% if config.MAPS_URL %} From b077c664e38cd4fafa7e8faafb8752caa3f53c51 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 09:57:00 -0400 Subject: [PATCH 21/54] Fixes #16542: Fix bulk form operations when HTMX is enabled --- netbox/utilities/templatetags/builtins/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index bc5c954be..d1dd1a55a 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,4 +1,5 @@ from django import template +from django.utils.safestring import mark_safe from extras.choices import CustomFieldTypeChoices from utilities.querydict import dict_to_querydict @@ -124,5 +125,5 @@ def formaction(context): if HTMX navigation is enabled (per the user's preferences). """ if context.get('htmx_navigation', False): - return 'hx-push-url="true" hx-post' + return mark_safe('hx-push-url="true" hx-post') return 'formaction' From d2a8e525851c11bb9aa682abf2b724caa0ad9da2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 11:57:01 -0400 Subject: [PATCH 22/54] Fixes #16444: Disable ordering circuits list by A/Z termination --- netbox/circuits/tables/circuits.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 5d650df61..e1b99ff42 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): status = columns.ChoiceFieldColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, + orderable=False, verbose_name=_('Side A') ) termination_z = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, + orderable=False, verbose_name=_('Side Z') ) commit_rate = CommitRateColumn( From 388ba3d7365cf5f2e8f2fc8a1e659b630f5b5420 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jun 2024 13:47:30 -0400 Subject: [PATCH 23/54] #16388: Rename database indexes & constraints --- .../migrations/0116_move_objectchange.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/netbox/extras/migrations/0116_move_objectchange.py b/netbox/extras/migrations/0116_move_objectchange.py index 5ac134004..b3833f7f8 100644 --- a/netbox/extras/migrations/0116_move_objectchange.py +++ b/netbox/extras/migrations/0116_move_objectchange.py @@ -44,6 +44,73 @@ class Migration(migrations.Migration): name='ObjectChange', table='core_objectchange', ), + + # Rename PK sequence + migrations.RunSQL( + "ALTER TABLE extras_objectchange_id_seq" + " RENAME TO core_objectchange_id_seq" + ), + + # Rename indexes. Hashes generated by schema_editor._create_index_name() + migrations.RunSQL( + "ALTER INDEX extras_objectchange_pkey" + " RENAME TO core_objectchange_pkey" + ), + migrations.RunSQL( + "ALTER INDEX extras_obje_changed_927fe5_idx" + " RENAME TO core_objectchange_changed_object_type_id_cha_79a9ed1e" + ), + migrations.RunSQL( + "ALTER INDEX extras_obje_related_bfcdef_idx" + " RENAME TO core_objectchange_related_object_type_id_rel_a71d604a" + ), + migrations.RunSQL( + "ALTER INDEX extras_objectchange_changed_object_type_id_b755bb60" + " RENAME TO core_objectchange_changed_object_type_id_2070ade6" + ), + migrations.RunSQL( + "ALTER INDEX extras_objectchange_related_object_type_id_fe6e521f" + " RENAME TO core_objectchange_related_object_type_id_b80958af" + ), + migrations.RunSQL( + "ALTER INDEX extras_objectchange_request_id_4ae21e90" + " RENAME TO core_objectchange_request_id_d9d160ac" + ), + migrations.RunSQL( + "ALTER INDEX extras_objectchange_time_224380ea" + " RENAME TO core_objectchange_time_800f60a5" + ), + migrations.RunSQL( + "ALTER INDEX extras_objectchange_user_id_7fdf8186" + " RENAME TO core_objectchange_user_id_2b2142be" + ), + + # Rename constraints + migrations.RunSQL( + "ALTER TABLE core_objectchange RENAME CONSTRAINT " + "extras_objectchange_changed_object_id_check TO " + "core_objectchange_changed_object_id_check" + ), + migrations.RunSQL( + "ALTER TABLE core_objectchange RENAME CONSTRAINT " + "extras_objectchange_related_object_id_check TO " + "core_objectchange_related_object_id_check" + ), + migrations.RunSQL( + "ALTER TABLE core_objectchange RENAME CONSTRAINT " + "extras_objectchange_changed_object_type__b755bb60_fk_django_co TO " + "core_objectchange_changed_object_type_id_2070ade6" + ), + migrations.RunSQL( + "ALTER TABLE core_objectchange RENAME CONSTRAINT " + "extras_objectchange_related_object_type__fe6e521f_fk_django_co TO " + "core_objectchange_related_object_type_id_b80958af" + ), + migrations.RunSQL( + "ALTER TABLE core_objectchange RENAME CONSTRAINT " + "extras_objectchange_user_id_7fdf8186_fk_auth_user_id TO " + "core_objectchange_user_id_2b2142be" + ), ], ), migrations.RunPython( From 1eebb98b56ecfc4b3008e34976254261059e507e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 05:02:24 +0000 Subject: [PATCH 24/54] Update source translation strings --- netbox/translations/en/LC_MESSAGES/django.po | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 17636c9e3..0cd3741e6 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-15 05:02+0000\n" +"POT-Creation-Date: 2024-06-18 05:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -158,7 +158,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:207 #: netbox/circuits/forms/model_forms.py:136 #: netbox/circuits/forms/model_forms.py:152 -#: netbox/circuits/tables/circuits.py:105 netbox/dcim/forms/bulk_edit.py:167 +#: netbox/circuits/tables/circuits.py:107 netbox/dcim/forms/bulk_edit.py:167 #: netbox/dcim/forms/bulk_edit.py:239 netbox/dcim/forms/bulk_edit.py:575 #: netbox/dcim/forms/bulk_edit.py:771 netbox/dcim/forms/bulk_import.py:130 #: netbox/dcim/forms/bulk_import.py:184 netbox/dcim/forms/bulk_import.py:257 @@ -308,7 +308,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:212 #: netbox/circuits/forms/model_forms.py:109 #: netbox/circuits/forms/model_forms.py:131 -#: netbox/circuits/tables/circuits.py:96 netbox/dcim/forms/connections.py:71 +#: netbox/circuits/tables/circuits.py:98 netbox/dcim/forms/connections.py:71 #: netbox/templates/circuits/circuit.html:15 #: netbox/templates/circuits/circuittermination.html:19 #: netbox/templates/dcim/inc/cable_termination.html:55 @@ -469,7 +469,7 @@ msgstr "" #: netbox/circuits/forms/model_forms.py:45 #: netbox/circuits/forms/model_forms.py:59 #: netbox/circuits/forms/model_forms.py:91 -#: netbox/circuits/tables/circuits.py:56 netbox/circuits/tables/circuits.py:100 +#: netbox/circuits/tables/circuits.py:56 netbox/circuits/tables/circuits.py:102 #: netbox/circuits/tables/providers.py:72 #: netbox/circuits/tables/providers.py:103 #: netbox/templates/circuits/circuit.html:18 @@ -748,7 +748,7 @@ msgstr "" #: netbox/circuits/forms/bulk_edit.py:191 #: netbox/circuits/forms/bulk_edit.py:215 #: netbox/circuits/forms/model_forms.py:153 -#: netbox/circuits/tables/circuits.py:109 +#: netbox/circuits/tables/circuits.py:111 #: netbox/templates/circuits/inc/circuit_termination_fields.html:62 #: netbox/templates/circuits/providernetwork.html:17 msgid "Provider Network" @@ -1328,21 +1328,21 @@ msgstr "" msgid "Circuit ID" msgstr "" -#: netbox/circuits/tables/circuits.py:66 +#: netbox/circuits/tables/circuits.py:67 #: netbox/wireless/forms/model_forms.py:160 msgid "Side A" msgstr "" -#: netbox/circuits/tables/circuits.py:70 +#: netbox/circuits/tables/circuits.py:72 msgid "Side Z" msgstr "" -#: netbox/circuits/tables/circuits.py:73 +#: netbox/circuits/tables/circuits.py:75 #: netbox/templates/circuits/circuit.html:55 msgid "Commit Rate" msgstr "" -#: netbox/circuits/tables/circuits.py:76 netbox/circuits/tables/providers.py:48 +#: netbox/circuits/tables/circuits.py:78 netbox/circuits/tables/providers.py:48 #: netbox/circuits/tables/providers.py:82 #: netbox/circuits/tables/providers.py:107 netbox/dcim/tables/devices.py:1001 #: netbox/dcim/tables/devicetypes.py:92 netbox/dcim/tables/modules.py:29 From 973bd0ed75426a907467ad0886e7041e441a595c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Jun 2024 08:17:08 -0400 Subject: [PATCH 25/54] Fixes #16512: Restore a user's preferred language on login (#16628) --- netbox/account/views.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/netbox/account/views.py b/netbox/account/views.py index 40ce78039..feb85fdfe 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -104,10 +104,16 @@ class LoginView(View): # Ensure the user has a UserConfig defined. (This should normally be handled by # create_userconfig() on user creation.) if not hasattr(request.user, 'config'): - config = get_config() - UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save() + request.user.config = get_config() + UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save() - return self.redirect_to_next(request, logger) + response = self.redirect_to_next(request, logger) + + # Set the user's preferred language (if any) + if language := request.user.config.get('locale.language'): + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language) + + return response else: logger.debug(f"Login form validation failed for username: {form['username'].value()}") @@ -145,9 +151,10 @@ class LogoutView(View): logger.info(f"User {username} has logged out") messages.info(request, "You have logged out.") - # Delete session key cookie (if set) upon logout + # Delete session key & language cookies (if set) upon logout response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL)) response.delete_cookie('session_key') + response.delete_cookie(settings.LANGUAGE_COOKIE_NAME) return response From cd9244fd4f7a74eb1de0dc7a418959e390d49a1f Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 18 Jun 2024 05:28:18 -0700 Subject: [PATCH 26/54] 16416 enable dark/light toggle in mobile view (#16635) * 16416 enable dark/light toggle in mobile view * 16416 move to inc file --- netbox/templates/base/layout.html | 10 ++-------- netbox/templates/inc/light_toggle.html | 10 ++++++++++ 2 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 netbox/templates/inc/light_toggle.html diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index d53591cb4..9ba6fded3 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -35,6 +35,7 @@ Blocks: {# User menu (mobile view) #} @@ -52,14 +53,7 @@ Blocks: