diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 42a716ae780..b4396873174 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.7 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index b04fda1b6e5..5df3069ba7c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.7 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b71fb515d9..301fac07936 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,25 @@
-Some general tips for engaging here on GitHub: +## :information_source: Welcome to the Stadium! + +In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well: + +> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers. + +The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users. + +If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them. + +NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others. + +### General Tips for Working on GitHub * Register for a free [GitHub account](https://github.com/signup) if you haven't already. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. +* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them. ## :bug: Reporting Bugs diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json new file mode 100644 index 00000000000..8dbcb284709 --- /dev/null +++ b/contrib/generated_schema.json @@ -0,0 +1,561 @@ +{ + "type": "object", + "additionalProperties": false, + "definitions": { + "airflow": { + "type": "string", + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "passive", + "mixed" + ] + }, + "weight-unit": { + "type": "string", + "enum": [ + "kg", + "g", + "lb", + "oz" + ] + }, + "subdevice-role": { + "type": "string", + "enum": [ + "parent", + "child" + ] + }, + "console-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "console-server-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "power-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c6", + "iec-60320-c8", + "iec-60320-c14", + "iec-60320-c16", + "iec-60320-c20", + "iec-60320-c22", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15p", + "nema-5-15p", + "nema-5-20p", + "nema-5-30p", + "nema-5-50p", + "nema-6-15p", + "nema-6-20p", + "nema-6-30p", + "nema-6-50p", + "nema-10-30p", + "nema-10-50p", + "nema-14-20p", + "nema-14-30p", + "nema-14-50p", + "nema-14-60p", + "nema-15-15p", + "nema-15-20p", + "nema-15-30p", + "nema-15-50p", + "nema-15-60p", + "nema-l1-15p", + "nema-l5-15p", + "nema-l5-20p", + "nema-l5-30p", + "nema-l5-50p", + "nema-l6-15p", + "nema-l6-20p", + "nema-l6-30p", + "nema-l6-50p", + "nema-l10-30p", + "nema-l14-20p", + "nema-l14-30p", + "nema-l14-50p", + "nema-l14-60p", + "nema-l15-20p", + "nema-l15-30p", + "nema-l15-50p", + "nema-l15-60p", + "nema-l21-20p", + "nema-l21-30p", + "nema-l22-30p", + "cs6361c", + "cs6365c", + "cs8165c", + "cs8265c", + "cs8365c", + "cs8465c", + "ita-c", + "ita-e", + "ita-f", + "ita-ef", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "usb-3-b", + "usb-3-micro-b", + "dc-terminal", + "saf-d-grid", + "neutrik-powercon-20", + "neutrik-powercon-32", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + } + } + }, + "power-outlet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c5", + "iec-60320-c7", + "iec-60320-c13", + "iec-60320-c15", + "iec-60320-c19", + "iec-60320-c21", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15r", + "nema-5-15r", + "nema-5-20r", + "nema-5-30r", + "nema-5-50r", + "nema-6-15r", + "nema-6-20r", + "nema-6-30r", + "nema-6-50r", + "nema-10-30r", + "nema-10-50r", + "nema-14-20r", + "nema-14-30r", + "nema-14-50r", + "nema-14-60r", + "nema-15-15r", + "nema-15-20r", + "nema-15-30r", + "nema-15-50r", + "nema-15-60r", + "nema-l1-15r", + "nema-l5-15r", + "nema-l5-20r", + "nema-l5-30r", + "nema-l5-50r", + "nema-l6-15r", + "nema-l6-20r", + "nema-l6-30r", + "nema-l6-50r", + "nema-l10-30r", + "nema-l14-20r", + "nema-l14-30r", + "nema-l14-50r", + "nema-l14-60r", + "nema-l15-20r", + "nema-l15-30r", + "nema-l15-50r", + "nema-l15-60r", + "nema-l21-20r", + "nema-l21-30r", + "nema-l22-30r", + "CS6360C", + "CS6364C", + "CS8164C", + "CS8264C", + "CS8364C", + "CS8464C", + "ita-e", + "ita-f", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "ita-multistandard", + "usb-a", + "usb-micro-b", + "usb-c", + "dc-terminal", + "hdot-cx", + "saf-d-grid", + "neutrik-powercon-20a", + "neutrik-powercon-32a", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + }, + "feed-leg": { + "type": "string", + "enum": [ + "A", + "B", + "C" + ] + } + } + }, + "interface": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "virtual", + "bridge", + "lag", + "100base-fx", + "100base-lfx", + "100base-tx", + "100base-t1", + "1000base-t", + "2.5gbase-t", + "5gbase-t", + "10gbase-t", + "10gbase-cx4", + "1000base-x-gbic", + "1000base-x-sfp", + "10gbase-x-sfpp", + "10gbase-x-xfp", + "10gbase-x-xenpak", + "10gbase-x-x2", + "25gbase-x-sfp28", + "50gbase-x-sfp56", + "40gbase-x-qsfpp", + "50gbase-x-sfp28", + "100gbase-x-cfp", + "100gbase-x-cfp2", + "200gbase-x-cfp2", + "100gbase-x-cfp4", + "100gbase-x-cxp", + "100gbase-x-cpak", + "100gbase-x-dsfp", + "100gbase-x-sfpdd", + "100gbase-x-qsfp28", + "100gbase-x-qsfpdd", + "200gbase-x-qsfp56", + "200gbase-x-qsfpdd", + "400gbase-x-qsfpdd", + "400gbase-x-osfp", + "400gbase-x-cdfp", + "400gbase-x-cfp8", + "800gbase-x-qsfpdd", + "800gbase-x-osfp", + "1000base-kx", + "10gbase-kr", + "10gbase-kx4", + "25gbase-kr", + "40gbase-kr4", + "50gbase-kr", + "100gbase-kp4", + "100gbase-kr2", + "100gbase-kr4", + "ieee802.11a", + "ieee802.11g", + "ieee802.11n", + "ieee802.11ac", + "ieee802.11ad", + "ieee802.11ax", + "ieee802.11ay", + "ieee802.15.1", + "other-wireless", + "gsm", + "cdma", + "lte", + "sonet-oc3", + "sonet-oc12", + "sonet-oc48", + "sonet-oc192", + "sonet-oc768", + "sonet-oc1920", + "sonet-oc3840", + "1gfc-sfp", + "2gfc-sfp", + "4gfc-sfp", + "8gfc-sfpp", + "16gfc-sfpp", + "32gfc-sfp28", + "64gfc-qsfpp", + "128gfc-qsfp28", + "infiniband-sdr", + "infiniband-ddr", + "infiniband-qdr", + "infiniband-fdr10", + "infiniband-fdr", + "infiniband-edr", + "infiniband-hdr", + "infiniband-ndr", + "infiniband-xdr", + "t1", + "e1", + "t3", + "e3", + "xdsl", + "docsis", + "gpon", + "xg-pon", + "xgs-pon", + "ng-pon2", + "epon", + "10g-epon", + "cisco-stackwise", + "cisco-stackwise-plus", + "cisco-flexstack", + "cisco-flexstack-plus", + "cisco-stackwise-80", + "cisco-stackwise-160", + "cisco-stackwise-320", + "cisco-stackwise-480", + "cisco-stackwise-1t", + "juniper-vcp", + "extreme-summitstack", + "extreme-summitstack-128", + "extreme-summitstack-256", + "extreme-summitstack-512", + "other" + ] + }, + "poe_mode": { + "type": "string", + "enum": [ + "pd", + "pse" + ] + }, + "poe_type": { + "type": "string", + "enum": [ + "type1-ieee802.3af", + "type2-ieee802.3at", + "type3-ieee802.3bt", + "type4-ieee802.3bt", + "passive-24v-2pair", + "passive-24v-4pair", + "passive-48v-2pair", + "passive-48v-4pair" + ] + } + } + }, + "front-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + }, + "rear-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + } + } +} diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index efb0f44b964..68b77711111 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. +### Rebuild Demo Data (After Release) + +After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions. + --- ## Patch Releases +### Notify netbox-docker Project of Any Relevant Changes + +Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including: + +* Significant changes to `upgrade.sh` +* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.) +* Any changes to the reference installation + ### Update Requirements Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this: @@ -58,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above). +### Rebuild the Device Type Definition Schema + +Run the following command to update the device type definition validation schema: + +```nohighlight +./manage.py buildschema --write +``` + +This will automatically update the schema file at `contrib/generated_schema.json`. + ### Update Version and Changelog * Update the `VERSION` constant in `settings.py` to the new release version. diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 5fc6961fcee..6d9ae550941 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,5 +1,32 @@ # NetBox v3.5 +## v3.5.8 (2023-08-15) + +### Enhancements + +* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release +* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import +* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI +* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type +* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table +* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses +* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page +* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form + +### Bug Fixes + +* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints +* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects +* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links +* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted +* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view +* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports +* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms +* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox +* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns + +--- + ## v3.5.7 (2023-07-28) ### Enhancements diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f1cfdd1d5d3..64dd8268200 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView): related_models = ( ( Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), - 'providernetwork_id', + 'provider_network_id', ), ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e850a8c514e..ba722508ab8 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -834,6 +834,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' + TYPE_400GE_CFP2 = '400gbase-x-cfp2' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_CDFP = '400gbase-x-cdfp' @@ -976,6 +977,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CFP, 'CFP (100GE)'), (TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'), + (TYPE_400GE_CFP2, 'CFP2 (400GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'), (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), @@ -1139,6 +1141,8 @@ class InterfaceSpeedChoices(ChoiceSet): (25000000, '25 Gbps'), (40000000, '40 Gbps'), (100000000, '100 Gbps'), + (200000000, '200 Gbps'), + (400000000, '400 Gbps'), ] diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 21921604502..3c02e6e4e71 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): queryset=VirtualDeviceContext.objects.all(), required=False, label='Virtual Device Contexts', + initial_params={ + 'interfaces': '$parent', + }, query_params={ 'device_id': '$device', } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 9589ab5335e..f37edee0a48 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -52,7 +52,10 @@ class ComponentCreateForm(forms.Form): super().clean() # Validate that all replication fields generate an equal number of values - pattern_count = len(self.cleaned_data[self.replication_fields[0]]) + if not (patterns := self.cleaned_data.get(self.replication_fields[0])): + return + + pattern_count = len(patterns) for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) if self.cleaned_data[field_name] and value_count != pattern_count: diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py index f3b8b696bf3..c97aa4c2b13 100644 --- a/netbox/dcim/graphql/gfk_mixins.py +++ b/netbox/dcim/graphql/gfk_mixins.py @@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == CircuitTermination: + if type(instance) is CircuitTermination: return CircuitTerminationType - if type(instance) == ConsolePortType: + if type(instance) is ConsolePortType: return ConsolePortType - if type(instance) == ConsoleServerPort: + if type(instance) is ConsoleServerPort: return ConsoleServerPortType - if type(instance) == FrontPort: + if type(instance) is FrontPort: return FrontPortType - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == PowerFeed: + if type(instance) is PowerFeed: return PowerFeedType - if type(instance) == PowerOutlet: + if type(instance) is PowerOutlet: return PowerOutletType - if type(instance) == PowerPort: + if type(instance) is PowerPort: return PowerPortType - if type(instance) == RearPort: + if type(instance) is RearPort: return RearPortType @@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == CircuitTermination: + if type(instance) is CircuitTermination: return CircuitTerminationType - if type(instance) == ConsolePortType: + if type(instance) is ConsolePortType: return ConsolePortType - if type(instance) == ConsoleServerPort: + if type(instance) is ConsoleServerPort: return ConsoleServerPortType - if type(instance) == FrontPort: + if type(instance) is FrontPort: return FrontPortType - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == PowerFeed: + if type(instance) is PowerFeed: return PowerFeedType - if type(instance) == PowerOutlet: + if type(instance) is PowerOutlet: return PowerOutletType - if type(instance) == PowerPort: + if type(instance) is PowerPort: return PowerPortType - if type(instance) == RearPort: + if type(instance) is RearPort: return RearPortType @@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == ConsolePortTemplate: + if type(instance) is ConsolePortTemplate: return ConsolePortTemplateType - if type(instance) == ConsoleServerPortTemplate: + if type(instance) is ConsoleServerPortTemplate: return ConsoleServerPortTemplateType - if type(instance) == FrontPortTemplate: + if type(instance) is FrontPortTemplate: return FrontPortTemplateType - if type(instance) == InterfaceTemplate: + if type(instance) is InterfaceTemplate: return InterfaceTemplateType - if type(instance) == PowerOutletTemplate: + if type(instance) is PowerOutletTemplate: return PowerOutletTemplateType - if type(instance) == PowerPortTemplate: + if type(instance) is PowerPortTemplate: return PowerPortTemplateType - if type(instance) == RearPortTemplate: + if type(instance) is RearPortTemplate: return RearPortTemplateType @@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == ConsolePort: + if type(instance) is ConsolePort: return ConsolePortType - if type(instance) == ConsoleServerPort: + if type(instance) is ConsoleServerPort: return ConsoleServerPortType - if type(instance) == FrontPort: + if type(instance) is FrontPort: return FrontPortType - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == PowerOutlet: + if type(instance) is PowerOutlet: return PowerOutletType - if type(instance) == PowerPort: + if type(instance) is PowerPort: return PowerPortType - if type(instance) == RearPort: + if type(instance) is RearPort: return RearPortType diff --git a/netbox/dcim/management/commands/buildschema.py b/netbox/dcim/management/commands/buildschema.py new file mode 100644 index 00000000000..44a0e95f274 --- /dev/null +++ b/netbox/dcim/management/commands/buildschema.py @@ -0,0 +1,62 @@ +import json +import os + +from django.conf import settings +from django.core.management.base import BaseCommand +from jinja2 import FileSystemLoader, Environment + +from dcim.choices import * + +TEMPLATE_FILENAME = 'devicetype_schema.jinja2' +OUTPUT_FILENAME = 'contrib/generated_schema.json' + +CHOICES_MAP = { + 'airflow_choices': DeviceAirflowChoices, + 'weight_unit_choices': WeightUnitChoices, + 'subdevice_role_choices': SubdeviceRoleChoices, + 'console_port_type_choices': ConsolePortTypeChoices, + 'console_server_port_type_choices': ConsolePortTypeChoices, + 'power_port_type_choices': PowerPortTypeChoices, + 'power_outlet_type_choices': PowerOutletTypeChoices, + 'power_outlet_feedleg_choices': PowerOutletFeedLegChoices, + 'interface_type_choices': InterfaceTypeChoices, + 'interface_poe_mode_choices': InterfacePoEModeChoices, + 'interface_poe_type_choices': InterfacePoETypeChoices, + 'front_port_type_choices': PortTypeChoices, + 'rear_port_type_choices': PortTypeChoices, +} + + +class Command(BaseCommand): + help = "Generate JSON schema for validating NetBox device type definitions" + + def add_arguments(self, parser): + parser.add_argument( + '--write', + action='store_true', + help="Write the generated schema to file" + ) + + def handle(self, *args, **kwargs): + # Initialize template + template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/') + template_env = Environment(loader=template_loader) + template = template_env.get_template(TEMPLATE_FILENAME) + + # Render template + context = { + key: json.dumps(choices.values()) + for key, choices in CHOICES_MAP.items() + } + rendered = template.render(**context) + + if kwargs['write']: + # $root/contrib/generated_schema.json + filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME) + with open(filename, mode='w', encoding='UTF-8') as f: + f.write(json.dumps(json.loads(rendered), indent=4)) + f.write('\n') + f.close() + self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}.")) + else: + self.stdout.write(rendered) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index db2655d2745..42b34e9993b 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi } ) mgmt_only = columns.BooleanColumn() + speed_formatted = columns.TemplateColumn( + template_code='{% load helpers %}{{ value|humanize_speed }}', + accessor=Accessor('speed'), + verbose_name='Speed' + ) wireless_link = tables.Column( linkify=True ) @@ -568,7 +573,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', + 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5b93e5f0b91..fca222f47ad 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,4 +1,5 @@ import traceback +from collections import defaultdict from django.contrib import messages from django.contrib.contenttypes.models import ContentType @@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = { class DeviceComponentsView(generic.ObjectChildrenView): + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + 'bulk_rename': {'change'}, + 'bulk_disconnect': {'change'}, + }) queryset = Device.objects.all() def get_children(self, request, parent): @@ -1997,6 +2007,7 @@ class DeviceModuleBaysView(DeviceComponentsView): table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet template_name = 'dcim/device/modulebays.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') tab = ViewTab( label=_('Module Bays'), badge=lambda obj: obj.modulebays.count(), @@ -2012,6 +2023,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet template_name = 'dcim/device/devicebays.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') tab = ViewTab( label=_('Device Bays'), badge=lambda obj: obj.devicebays.count(), @@ -2023,6 +2035,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): @register_model_view(Device, 'inventory') class DeviceInventoryView(DeviceComponentsView): + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') child_model = InventoryItem table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index fcf5c26a283..c76a5a76f87 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote(link, safe='/:?&=%+[]@#,') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;') # Verify link scheme is allowed result = urllib.parse.urlparse(link) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 83c7a7bb0e2..8736a319794 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -2,7 +2,6 @@ import collections from importlib import import_module from django.apps import AppConfig -from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version @@ -146,23 +145,3 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value - - -# -# Utilities -# - -def get_plugin_config(plugin_name, parameter, default=None): - """ - Return the value of the specified plugin configuration parameter. - - Args: - plugin_name: The name of the plugin - parameter: The name of the configuration parameter - default: The value to return if the parameter is not defined (default: None) - """ - try: - plugin_config = settings.PLUGINS_CONFIG[plugin_name] - return plugin_config.get(parameter, default) - except KeyError: - raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py new file mode 100644 index 00000000000..c260f156db6 --- /dev/null +++ b/netbox/extras/plugins/utils.py @@ -0,0 +1,37 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +__all__ = ( + 'get_installed_plugins', + 'get_plugin_config', +) + + +def get_installed_plugins(): + """ + Return a dictionary mapping the names of installed plugins to their versions. + """ + plugins = {} + for plugin_name in settings.PLUGINS: + plugin_name = plugin_name.rsplit('.', 1)[-1] + plugin_config = apps.get_app_config(plugin_name) + plugins[plugin_name] = getattr(plugin_config, 'version', None) + + return dict(sorted(plugins.items())) + + +def get_plugin_config(plugin_name, parameter, default=None): + """ + Return the value of the specified plugin configuration parameter. + + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) + """ + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 8f3af2a09ac..6af81a9d950 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -214,20 +214,18 @@ class Report(object): self.active_test = method_name test_method = getattr(self, method_name) test_method() + job.data = self._results if self.failed: self.logger.warning("Report failed") - job.status = JobStatusChoices.STATUS_FAILED + job.terminate(status=JobStatusChoices.STATUS_FAILED) else: self.logger.info("Report completed successfully") - job.status = JobStatusChoices.STATUS_COMPLETED + job.terminate() except Exception as e: stacktrace = traceback.format_exc() self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}{stacktrace}")
logger.error(f"Exception raised during report execution: {e}")
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
- finally:
- job.data = self._results
- job.terminate()
# Perform any post-run tasks
self.post_run()
diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py
index cb7629ad24e..42dde43fdff 100644
--- a/netbox/extras/tests/test_plugins.py
+++ b/netbox/extras/tests/test_plugins.py
@@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings
from django.urls import reverse
-from extras.plugins import PluginMenu, get_plugin_config
+from extras.plugins import PluginMenu
from extras.tests.dummy_plugin import config as dummy_config
+from extras.plugins.utils import get_plugin_config
from netbox.graphql.schema import Query
from netbox.registry import registry
diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py
index 19264dabbeb..ef76377652d 100644
--- a/netbox/extras/tests/test_webhooks.py
+++ b/netbox/extras/tests/test_webhooks.py
@@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
- DUMMY_URL = "http://localhost/"
- DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
+ DUMMY_URL = 'http://localhost:9000/'
+ DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
webhooks = Webhook.objects.bulk_create((
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
@@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
name='Conditional Webhook',
type_create=True,
type_update=True,
- payload_url='http://localhost/',
+ payload_url='http://localhost:9000/',
conditions={
'and': [
{
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index 99b4c023d95..feffc3ff2b9 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -224,7 +224,10 @@ class AvailableASNsView(ObjectValidationMixin, APIView):
return Response(serializer.data)
- @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)})
+ @extend_schema(methods=["post"],
+ responses={201: serializers.ASNSerializer(many=True)},
+ request=serializers.ASNSerializer(many=True),
+ )
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
@@ -293,7 +296,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView):
return Response(serializer.data)
- @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
+ @extend_schema(methods=["post"],
+ responses={201: serializers.PrefixSerializer(many=True)},
+ request=serializers.PrefixSerializer(many=True),
+ )
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
@@ -388,7 +394,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
return Response(serializer.data)
- @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
+ @extend_schema(methods=["post"],
+ responses={201: serializers.IPAddressSerializer(many=True)},
+ request=serializers.IPAddressSerializer(many=True),
+ )
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
@@ -468,7 +477,10 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
return Response(serializer.data)
- @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
+ @extend_schema(methods=["post"],
+ responses={201: serializers.VLANSerializer(many=True)},
+ request=serializers.VLANSerializer(many=True),
+ )
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index d011472d932..9b57cb273cf 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='_assigned_to_interface',
label=_('Is assigned to an interface'),
)
+ assigned = django_filters.BooleanFilter(
+ method='_assigned',
+ label=_('Is assigned'),
+ )
status = django_filters.MultipleChoiceFilter(
choices=IPAddressStatusChoices,
null_value=None
@@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
assigned_object_id__isnull=False
)
+ def _assigned(self, queryset, name, value):
+ if value:
+ return queryset.exclude(
+ assigned_object_type__isnull=True,
+ assigned_object_id__isnull=True
+ )
+ else:
+ return queryset.filter(
+ assigned_object_type__isnull=True,
+ assigned_object_id__isnull=True
+ )
+
class FHRPGroupFilterSet(NetBoxModelFilterSet):
protocol = django_filters.MultipleChoiceFilter(
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index 683d40f4908..3bce26249a5 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -1,7 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
-from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Site
@@ -10,7 +9,9 @@ from ipam.constants import *
from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import (
+ CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
+)
from virtualization.models import VirtualMachine, VMInterface
__all__ = (
@@ -41,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Assigned tenant')
)
+ import_targets = CSVModelMultipleChoiceField(
+ queryset=RouteTarget.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Import route targets')
+ )
+ export_targets = CSVModelMultipleChoiceField(
+ queryset=RouteTarget.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Export route targets')
+ )
class Meta:
model = VRF
- fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
+ fields = (
+ 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
+ 'tags',
+ )
class RouteTargetImportForm(NetBoxModelImportForm):
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 53fecfe2f7c..f000828635e 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -253,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
- ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
+ ('Attributes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
family = forms.ChoiceField(
diff --git a/netbox/ipam/graphql/gfk_mixins.py b/netbox/ipam/graphql/gfk_mixins.py
index 31742c4a48a..01c79690a2b 100644
--- a/netbox/ipam/graphql/gfk_mixins.py
+++ b/netbox/ipam/graphql/gfk_mixins.py
@@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
- if type(instance) == Interface:
+ if type(instance) is Interface:
return InterfaceType
- if type(instance) == FHRPGroup:
+ if type(instance) is FHRPGroup:
return FHRPGroupType
- if type(instance) == VMInterface:
+ if type(instance) is VMInterface:
return VMInterfaceType
@@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
- if type(instance) == Interface:
+ if type(instance) is Interface:
return InterfaceType
- if type(instance) == VLAN:
+ if type(instance) is VLAN:
return VLANType
- if type(instance) == VMInterface:
+ if type(instance) is VMInterface:
return VMInterfaceType
@@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
- if type(instance) == Interface:
+ if type(instance) is Interface:
return InterfaceType
- if type(instance) == VMInterface:
+ if type(instance) is VMInterface:
return VMInterfaceType
@@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
- if type(instance) == Cluster:
+ if type(instance) is Cluster:
return ClusterType
- if type(instance) == ClusterGroup:
+ if type(instance) is ClusterGroup:
return ClusterGroupType
- if type(instance) == Location:
+ if type(instance) is Location:
return LocationType
- if type(instance) == Rack:
+ if type(instance) is Rack:
return RackType
- if type(instance) == Region:
+ if type(instance) is Region:
return RegionType
- if type(instance) == Site:
+ if type(instance) is Site:
return SiteType
- if type(instance) == SiteGroup:
+ if type(instance) is SiteGroup:
return SiteGroupType
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index 3d9a6656713..0ae7544ab9a 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_assigned(self):
+ params = {'assigned': 'true'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+ params = {'assigned': 'false'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py
index 93a40e5a0c5..262fd8d46b2 100644
--- a/netbox/ipam/utils.py
+++ b/netbox/ipam/utils.py
@@ -121,7 +121,7 @@ def add_available_vlans(vlans, vlan_group=None):
})
vlans = list(vlans) + new_vlans
- vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
+ vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
return vlans
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 32badd2d503..d8e4d8b4785 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -216,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
- template_name = 'ipam/asnrange/asns.html'
+ template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -816,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView):
table = None
if form.is_valid():
-
addresses = self.queryset.prefetch_related('vrf', 'tenant')
# Limit to 100 results
addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
@@ -866,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
- template_name = 'ipam/ipaddress/ip_addresses.html'
+ template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -963,7 +962,6 @@ class FHRPGroupView(generic.ObjectView):
queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance):
-
# Get assigned interfaces
members_table = tables.FHRPGroupAssignmentTable(
data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
@@ -1077,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
- template_name = 'ipam/vlan/interfaces.html'
+ template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1095,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
- template_name = 'ipam/vlan/vminterfaces.html'
+ template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index 5c55697ff6c..97f690762b7 100644
--- a/netbox/netbox/api/views.py
+++ b/netbox/netbox/api/views.py
@@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rq.worker import Worker
+from extras.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -61,19 +62,11 @@ class StatusView(APIView):
installed_apps[app_config.name] = version
installed_apps = {k: v for k, v in sorted(installed_apps.items())}
- # Gather installed plugins
- plugins = {}
- for plugin_name in settings.PLUGINS:
- plugin_name = plugin_name.rsplit('.', 1)[-1]
- plugin_config = apps.get_app_config(plugin_name)
- plugins[plugin_name] = getattr(plugin_config, 'version', None)
- plugins = {k: v for k, v in sorted(plugins.items())}
-
return Response({
'django-version': DJANGO_VERSION,
'installed-apps': installed_apps,
'netbox-version': settings.VERSION,
- 'plugins': plugins,
+ 'plugins': get_installed_plugins(),
'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')),
})
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index 83c238e0f36..b406ab04e7a 100644
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -78,7 +78,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).filter(
- ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
+ ui_visibility__in=[
+ CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
+ CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET,
+ ]
)
def _get_form_field(self, customfield):
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 8bacba5345b..1e55ec2a34e 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -442,6 +442,19 @@ class SyncedDataMixin(models.Model):
return ret
+ def delete(self, *args, **kwargs):
+ from core.models import AutoSyncRecord
+
+ # Delete AutoSyncRecord
+ content_type = ContentType.objects.get_for_model(self)
+ AutoSyncRecord.objects.filter(
+ datafile=self.data_file,
+ object_type=content_type,
+ object_id=self.pk
+ ).delete()
+
+ return super().delete(*args, **kwargs)
+
def resolve_data_file(self):
"""
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index aec4f76f6eb..acad437fcfc 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
-VERSION = '3.5.7'
+VERSION = '3.5.8'
# Hostname
HOSTNAME = platform.node()
@@ -461,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
-TEST_RUNNER = "django_rich.test.RichRunner"
-
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
EXEMPT_EXCLUDE_MODELS = (
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index 9ef32702622..399b3c18476 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -504,9 +504,9 @@ class CustomLinkColumn(tables.Column):
"""
def __init__(self, customlink, *args, **kwargs):
self.customlink = customlink
- kwargs['accessor'] = Accessor('pk')
- if 'verbose_name' not in kwargs:
- kwargs['verbose_name'] = customlink.name
+ kwargs.setdefault('accessor', Accessor('pk'))
+ kwargs.setdefault('orderable', False)
+ kwargs.setdefault('verbose_name', customlink.name)
super().__init__(*args, **kwargs)
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
index 20eab822db4..975311e4a82 100644
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -54,7 +54,7 @@ class BaseTable(tables.Table):
# 3. Meta.fields
selected_columns = None
if user is not None and not isinstance(user, AnonymousUser):
- selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
+ selected_columns = user.config.get(f"tables.{self.name}.columns")
if not selected_columns:
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
@@ -113,6 +113,10 @@ class BaseTable(tables.Table):
columns.append((name, column.verbose_name))
return columns
+ @property
+ def name(self):
+ return self.__class__.__name__
+
@property
def available_columns(self):
return self._get_columns(visible=False)
@@ -138,17 +142,16 @@ class BaseTable(tables.Table):
"""
# Save ordering preference
if request.user.is_authenticated:
- table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
- request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
+ request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
else:
# If the ordering has been set to none (empty), clear any existing preference.
- request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
- elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
+ request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
+ elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering
diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py
index c74c67cef06..a81d45cb5cb 100644
--- a/netbox/netbox/views/errors.py
+++ b/netbox/netbox/views/errors.py
@@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
from sentry_sdk import capture_message
+from extras.plugins.utils import get_installed_plugins
+
__all__ = (
'handler_404',
'handler_500',
@@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
'exception': str(type_),
'netbox_version': settings.VERSION,
'python_version': platform.python_version(),
+ 'plugins': get_installed_plugins(),
}))
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 1ba789cf187..99d8ff5401c 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
return render(request, self.get_template_name(), {
'object': instance,
'child_model': self.child_model,
+ 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table,
+ 'table_config': f'{table.name}_config',
'actions': actions,
'tab': self.tab,
+ 'return_url': request.get_full_path(),
**self.get_extra_context(request, instance),
})
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index b62436d757a..84bfecae349 100644
--- a/netbox/project-static/dist/netbox.js
+++ b/netbox/project-static/dist/netbox.js
@@ -1,11 +1,11 @@
-(()=>{var j_=Object.create;var ia=Object.defineProperty,H_=Object.defineProperties,B_=Object.getOwnPropertyDescriptor,F_=Object.getOwnPropertyDescriptors,z_=Object.getOwnPropertyNames,hh=Object.getOwnPropertySymbols,$_=Object.getPrototypeOf,ph=Object.prototype.hasOwnProperty,U_=Object.prototype.propertyIsEnumerable;var lu=(yr,br,mr)=>br in yr?ia(yr,br,{enumerable:!0,configurable:!0,writable:!0,value:mr}):yr[br]=mr,ci=(yr,br)=>{for(var mr in br||(br={}))ph.call(br,mr)&&lu(yr,mr,br[mr]);if(hh)for(var mr of hh(br))U_.call(br,mr)&&lu(yr,mr,br[mr]);return yr},al=(yr,br)=>H_(yr,F_(br)),mh=yr=>ia(yr,"__esModule",{value:!0});var jr=(yr,br)=>()=>(br||yr((br={exports:{}}).exports,br),br.exports),V_=(yr,br)=>{mh(yr);for(var mr in br)ia(yr,mr,{get:br[mr],enumerable:!0})},W_=(yr,br,mr)=>{if(br&&typeof br=="object"||typeof br=="function")for(let Er of z_(br))!ph.call(yr,Er)&&Er!=="default"&&ia(yr,Er,{get:()=>br[Er],enumerable:!(mr=B_(br,Er))||mr.enumerable});return yr},vn=yr=>W_(mh(ia(yr!=null?j_($_(yr)):{},"default",yr&&yr.__esModule&&"default"in yr?{get:()=>yr.default,enumerable:!0}:{value:yr,enumerable:!0})),yr);var di=(yr,br,mr)=>(lu(yr,typeof br!="symbol"?br+"":br,mr),mr);var $i=(yr,br,mr)=>new Promise((Er,wr)=>{var _r=Tr=>{try{Sr(mr.next(Tr))}catch(Dr){wr(Dr)}},xr=Tr=>{try{Sr(mr.throw(Tr))}catch(Dr){wr(Dr)}},Sr=Tr=>Tr.done?Er(Tr.value):Promise.resolve(Tr.value).then(_r,xr);Sr((mr=mr.apply(yr,br)).next())});var Jp=jr((exports,module)=>{(function(yr,br){typeof define=="function"&&define.amd?define([],br):yr.htmx=yr.htmx||br()})(typeof self!="undefined"?self:exports,function(){return function(){"use strict";var U={onLoad:t,process:vt,on:X,off:F,trigger:$,ajax:nr,find:R,findAll:O,closest:N,values:function(yr,br){var mr=Pt(yr,br||"post");return mr.values},remove:q,addClass:L,removeClass:T,toggleClass:A,takeClass:H,defineExtension:fr,removeExtension:cr,logAll:C,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:!1,scrollBehavior:"smooth",defaultFocusScroll:!1},parseInterval:v,_:e,createEventSource:function(yr){return new EventSource(yr,{withCredentials:!0})},createWebSocket:function(yr){return new WebSocket(yr,[])},version:"1.8.0"},r={addTriggerHandler:st,bodyContains:K,canAccessLocalStorage:E,filterValues:Ut,hasAttribute:o,getAttributeValue:V,getClosestMatch:h,getExpressionVars:Qt,getHeaders:Bt,getInputValues:Pt,getInternalData:W,getSwapSpecification:_t,getTriggerSpecs:Me,getTarget:re,makeFragment:g,mergeObjects:Y,makeSettleInfo:Gt,oobSwap:ae,selectAndSwap:Ee,settleImmediately:Lt,shouldCancel:je,triggerEvent:$,triggerErrorEvent:J,withExtensions:xt},n=["get","post","put","delete","patch"],i=n.map(function(yr){return"[hx-"+yr+"], [data-hx-"+yr+"]"}).join(", ");function v(yr){if(yr!=null)return yr.slice(-2)=="ms"?parseFloat(yr.slice(0,-2))||void 0:yr.slice(-1)=="s"?parseFloat(yr.slice(0,-1))*1e3||void 0:yr.slice(-1)=="m"?parseFloat(yr.slice(0,-1))*1e3*60||void 0:parseFloat(yr)||void 0}function f(yr,br){return yr.getAttribute&&yr.getAttribute(br)}function o(yr,br){return yr.hasAttribute&&(yr.hasAttribute(br)||yr.hasAttribute("data-"+br))}function V(yr,br){return f(yr,br)||f(yr,"data-"+br)}function u(yr){return yr.parentElement}function _(){return document}function h(yr,br){for(;yr&&!br(yr);)yr=u(yr);return yr||null}function a(yr,br,mr){var Er=V(br,mr),wr=V(br,"hx-disinherit");return yr!==br&&wr&&(wr==="*"||wr.split(" ").indexOf(mr)>=0)?"unset":Er}function z(yr,br){var mr=null;if(h(yr,function(Er){return mr=a(yr,Er,br)}),mr!=="unset")return mr}function d(yr,br){var mr=yr.matches||yr.matchesSelector||yr.msMatchesSelector||yr.mozMatchesSelector||yr.webkitMatchesSelector||yr.oMatchesSelector;return mr&&mr.call(yr,br)}function s(yr){var br=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,mr=br.exec(yr);return mr?mr[1].toLowerCase():""}function l(yr,br){for(var mr=new DOMParser,Er=mr.parseFromString(yr,"text/html"),wr=Er.body;br>0;)br--,wr=wr.firstChild;return wr==null&&(wr=_().createDocumentFragment()),wr}function g(yr){if(U.config.useTemplateFragments){var br=l(""+yr+"",0);return br.querySelector("template").content}else{var mr=s(yr);switch(mr){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return l("