diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index be2aacff5..b43968731 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.1 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1f8fdebd4..e6a5e76c2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,10 +3,13 @@ blank_issues_enabled: false contact_links: - name: 📖 Contributing Policy url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md - about: "Please read through our contributing policy before opening an issue or pull request" + about: "Please read through our contributing policy before opening an issue or pull request." - name: ❓ Discussion url: https://github.com/netbox-community/netbox/discussions - about: "If you're just looking for help, try starting a discussion instead" + about: "If you're just looking for help, try starting a discussion instead." + - name: 💡 Plugin Idea + url: https://plugin-ideas.netbox.dev + about: "Have an idea for a plugin? Head over to the ideas board!" - name: 💬 Community Slack - url: https://netdev.chat/ - about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" + url: https://netdev.chat + about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index fcb3516b4..5df3069ba 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.1 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b71fb515..301fac079 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/README.md b/README.md index 480f0f856..54b3e727e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@
NetBox logo - - The premiere source of truth powering network automation +

The premiere source of truth powering network automation

+ CI status +

-![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) - NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, @@ -53,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations. ## Project Stats
- Timeline graph - Issues graph - Pull requests graph - Top contributors + Timeline graph + Issues graph + Pull requests graph + Top contributors
Stats via Repography
@@ -67,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations. [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)            [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) -
- [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            + [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io) +
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) +            + [![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com) diff --git a/base_requirements.txt b/base_requirements.txt index 1e9a45048..40e0224e2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -84,7 +84,8 @@ feedparser # Django wrapper for Graphene (GraphQL support) # https://github.com/graphql-python/graphene-django/releases -graphene_django +# Pinned to v3.0.0 for GraphiQL UI issue (see #12762) +graphene_django==3.0.0 # WSGI HTTP server # https://docs.gunicorn.org/en/latest/news.html diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json new file mode 100644 index 000000000..8dbcb2847 --- /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/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index c3fbb40aa..fd410a9d4 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo --- +## BANNER_MAINTENANCE + +!!! tip "Dynamic Configuration Parameter" + +!!! note + This parameter was added in NetBox v3.5. + +This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed. + +--- + ## BANNER_TOP !!! tip "Dynamic Configuration Parameter" @@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne Default: `300` The maximum execution time of a background task (such as running a custom script), in seconds. + +--- + +## RQ_RETRY_INTERVAL + +!!! note + This parameter was added in NetBox v3.5. + +Default: `60` + +This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour. + +--- + +## RQ_RETRY_MAX + +!!! note + This parameter was added in NetBox v3.5. + +Default: `0` (retries disabled) + +The maximum number of times a background task will be retried before being marked as failed. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index fd95adef5..fb789bd98 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo --- +## REMOTE_AUTH_AUTO_CREATE_GROUPS + +Default: `False` + +If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + ## REMOTE_AUTH_AUTO_CREATE_USER Default: `False` diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index efb0f44b9..68b777111 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/features/ipam.md b/docs/features/ipam.md index d67645b17..3cbe4319d 100644 --- a/docs/features/ipam.md +++ b/docs/features/ipam.md @@ -38,7 +38,7 @@ An example hierarchy might look like this: * 100.64.16.1/24 (address) * 100.64.16.2/24 (address) * 100.64.16.3/24 (address) - * 100.64.16.9/24 (prefix) + * 100.64.19.0/24 (prefix) * 100.64.32.0/20 (prefix) * 100.64.32.1/24 (address) * 100.64.32.10-99/24 (range) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 1fccd0270..f2e1ea356 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -55,6 +55,9 @@ Within the shell, enter the following commands to create the database and user ( CREATE DATABASE netbox; CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; ALTER DATABASE netbox OWNER TO netbox; +-- the next two commands are needed on PostgreSQL 15 and later +\connect netbox; +GRANT CREATE ON SCHEMA public TO netbox; ``` !!! danger "Use a strong password" diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index dc6c38977..e0e656ce9 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s ``` sudo adduser --system --group netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ + sudo chown --recursive netbox /opt/netbox/netbox/reports/ + sudo chown --recursive netbox /opt/netbox/netbox/scripts/ ``` === "CentOS" @@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s sudo groupadd --system netbox sudo adduser --system -g netbox netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ + sudo chown --recursive netbox /opt/netbox/netbox/reports/ + sudo chown --recursive netbox /opt/netbox/netbox/scripts/ ``` ## Configuration diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 27401c3cf..adcd91310 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/ Download and extract the latest version: ```no-highlight -wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz -sudo tar -xzf vX.Y.Z.tar.gz -C /opt -sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox +# Set $NEWVER to the NetBox version being installed +NEWVER=3.5.0 +wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz +sudo tar -xzf v$NEWVER.tar.gz -C /opt +sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox ``` Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version: ```no-highlight -sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/ -sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ -sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ +# Set $OLDVER to the NetBox version currently installed +OLDVER=3.4.9 +sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/ +sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ +sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ ``` Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.) ```no-highlight -sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/ +sudo cp -pr /opt/netbox-$OLDVER/netbox/media/ /opt/netbox/netbox/ ``` Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.) ```no-highlight -sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/ -sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/ +sudo cp -r /opt/netbox-$OLDVER/netbox/scripts /opt/netbox/netbox/ +sudo cp -r /opt/netbox-$OLDVER/netbox/reports /opt/netbox/netbox/ ``` If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: ```no-highlight -sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/ +sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/ ``` ### Option B: Clone the Git Repository diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index af2f86e4c..cf3d11126 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object ## Interactive Documentation -Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. +Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. ## Endpoint Hierarchy diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 0d92ec656..df0408f7c 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values. Controls how and whether the custom field is displayed within the NetBox user interface. -| Option | Description | -|------------|--------------------------------------| -| Read/write | Display and permit editing (default) | -| Read-only | Display field but disallow editing | -| Hidden | Do not display field in the UI | +| Option | Description | +|-------------------|--------------------------------------------------| +| Read/write | Display and permit editing (default) | +| Read-only | Display field but disallow editing | +| Hidden | Do not display field in the UI | +| Hidden (if unset) | Display in the UI only when a value has been set | ### Default diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index b3bcb292a..c51d025f4 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -19,6 +19,9 @@ class MyModel(models.Model): Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`. +!!! note + Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions. + ## Enabling NetBox Features Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 36dc97bb9..f7778275b 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,9 +1,166 @@ # NetBox v3.5 -## v3.5.2 (FUTURE) +## v3.5.9 (FUTURE) + +--- + +## 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 + +* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view +* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source +* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types +* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results +* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types + +### Bug Fixes + +* [#13051](https://github.com/netbox-community/netbox/issues/13051) - Fix Markdown support for table cell alignment +* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API +* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces +* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false +* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value + +--- + +## v3.5.6 (2023-07-10) + +### Bug Fixes + +* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined +* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled +* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized +* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types + +--- + +## v3.5.5 (2023-07-06) + +### Enhancements + +* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization +* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses +* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type +* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table +* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log + +### Bug Fixes + +* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records +* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes +* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable +* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address +* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs +* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports +* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients +* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view +* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment +* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields +* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs +* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled +* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer +* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets +* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit +* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types +* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links +* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list +* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer +* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit +* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values + +--- + +## v3.5.4 (2023-06-20) + +### Enhancements + +* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices +* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views +* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly +* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu + +### Bug Fixes + +* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site +* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site +* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint +* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces +* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job +* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs +* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values +* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table +* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list +* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100 +* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request + +--- + +## v3.5.3 (2023-06-02) + +### Enhancements + +* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules +* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components +* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration +* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures +* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset +* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets + +### Bug Fixes + +* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API +* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value +* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object +* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment +* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables +* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text +* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form +* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object +* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view +* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters +* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version +* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets +* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters + +--- + +## v3.5.2 (2023-05-22) + +### Enhancements + +* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use +* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces +* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws +* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled +* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views * [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import * [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations * [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views @@ -11,11 +168,23 @@ * [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab * [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view * [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type +* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty +* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments +* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner +* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types +* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types +* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type ### Bug Fixes +* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables +* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit +* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores * [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form +* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute +* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget +* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form --- diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e5f4faee1..64dd82682 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,10 +1,10 @@ from django.contrib import messages from django.db import transaction -from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from dcim.views import PathTraceView from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.utils import count_related from utilities.views import register_model_view @@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable +@register_model_view(Provider, 'contacts') +class ProviderContactsView(ObjectContactsView): + queryset = Provider.objects.all() + + # # ProviderAccounts # @@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderAccountTable +@register_model_view(ProviderAccount, 'contacts') +class ProviderAccountContactsView(ObjectContactsView): + queryset = ProviderAccount.objects.all() + + # # Provider networks # @@ -153,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', ), ) @@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView): }) +@register_model_view(Circuit, 'contacts') +class CircuitContactsView(ObjectContactsView): + queryset = Circuit.objects.all() + + # # Circuit terminations # diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 9550df3ea..b7e537c23 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -1,5 +1,6 @@ import re import typing +from collections import OrderedDict from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.openapi import AutoSchema @@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension): target_class = 'netbox.api.fields.ChoiceField' def map_serializer_field(self, auto_schema, direction): + build_cf = build_choice_field(self.target) + if direction == 'request': - return build_choice_field(self.target) + return build_cf elif direction == "response": + value = build_cf + label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))} + return build_object_type( properties={ - "value": build_basic_type(OpenApiTypes.STR), - "label": build_basic_type(OpenApiTypes.STR), + "value": value, + "label": label } ) diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index fc4ef2927..7bf2f87a6 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet): """ Enqueue a job to synchronize the DataSource. """ - if not request.user.has_perm('extras.sync_datasource'): + if not request.user.has_perm('core.sync_datasource'): raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") datasource = get_object_or_404(DataSource, pk=pk) diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 6cc534774..8863e1aef 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -41,6 +41,7 @@ def register_backend(name): class DataBackend: parameters = {} + sensitive_parameters = [] def __init__(self, url, **kwargs): self.url = url @@ -86,6 +87,7 @@ class GitBackend(DataBackend): widget=forms.TextInput(attrs={'class': 'form-control'}) ) } + sensitive_parameters = ['password'] @contextmanager def fetch(self): @@ -101,12 +103,13 @@ class GitBackend(DataBackend): } if self.url_scheme in ('http', 'https'): - clone_args.update( - { - "username": self.params.get('username'), - "password": self.params.get('password'), - } - ) + if self.params.get('username'): + clone_args.update( + { + "username": self.params.get('username'), + "password": self.params.get('password'), + } + ) if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): if proxy := settings.HTTP_PROXIES.get(self.url_scheme): @@ -135,6 +138,7 @@ class S3Backend(DataBackend): widget=forms.TextInput(attrs={'class': 'form-control'}) ), } + sensitive_parameters = ['aws_secret_access_key'] REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com' diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 1d0eecd21..a91e75e61 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -16,7 +16,7 @@ from extras.utils import FeatureQuery from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet -from utilities.rqworker import get_queue_for_model +from utilities.rqworker import get_queue_for_model, get_rq_retry __all__ = ( 'Job', @@ -219,5 +219,6 @@ class Job(models.Model): event=event, data=self.data, timestamp=str(timezone.now()), - username=self.user.username + username=self.user.username, + retry=get_rq_retry() ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3f6d55da7..9cf30fdd4 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -698,7 +698,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', + 'created', 'last_updated', ] @extend_schema_field(serializers.JSONField(allow_null=True)) @@ -707,7 +708,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') device = NestedDeviceSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) @@ -880,12 +881,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -907,9 +908,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect mac_address = serializers.CharField( required=False, default=None, + allow_blank=True, allow_null=True ) - wwn = serializers.CharField(required=False, default=None) + wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) class Meta: model = Interface diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0d9fcdc5f..e8a2eabbf 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,12 +1,12 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework.decorators import action from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.routers import APIRootView +from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.viewsets import ViewSet from circuits.models import Circuit @@ -14,7 +14,6 @@ from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * from dcim.svg import CableTraceSVG -from extras.api.nested_serializers import NestedConfigTemplateSerializer from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.utils import count_related @@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet): # Devices/modules # -class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): +class DeviceViewSet( + SequentialBulkCreatesMixin, + ConfigContextQuerySetMixin, + ConfigTemplateRenderMixin, + NetBoxModelViewSet +): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', @@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations', + 'vdcs', ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -640,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet): def get_view_name(self): return "Connected Device Locator" - @extend_schema(responses={200: OpenApiTypes.OBJECT}) + @extend_schema( + parameters=[_device_param, _interface_param], + responses={200: serializers.DeviceSerializer} + ) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d32f5aaee..ba722508a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' + # IEC 60906-1 + TYPE_IEC_60906_1 = 'iec-60906-1' + TYPE_NBR_14136_10A = 'nbr-14136-10a' + TYPE_NBR_14136_20A = 'nbr-14136-20a' # NEMA non-locking TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_515P = 'nema-5-15p' @@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), + ('IEC 60906-1', ( + (TYPE_IEC_60906_1, 'IEC 60906-1'), + (TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'), + (TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'), + )), ('NEMA (Non-locking)', ( (TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'), @@ -553,6 +562,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' + # IEC 60906-1 + TYPE_IEC_60906_1 = 'iec-60906-1' + TYPE_NBR_14136_10A = 'nbr-14136-10a' + TYPE_NBR_14136_20A = 'nbr-14136-20a' # NEMA non-locking TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_515R = 'nema-5-15r' @@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), + ('IEC 60906-1', ( + (TYPE_IEC_60906_1, 'IEC 60906-1'), + (TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'), + (TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'), + )), ('NEMA (Non-locking)', ( (TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'), @@ -809,11 +827,18 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100GE_CFP4 = '100gbase-x-cfp4' TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CPAK = '100gbase-x-cpak' + TYPE_100GE_DSFP = '100gbase-x-dsfp' + TYPE_100GE_SFP_DD = '100gbase-x-sfpdd' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' + TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd' 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' + TYPE_400GE_CFP8 = '400gbase-x-cfp8' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_OSFP = '800gbase-x-osfp' @@ -952,13 +977,20 @@ 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)'), + (TYPE_100GE_DSFP, 'DSFP (100GE)'), + (TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), + (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), + (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'), + (TYPE_400GE_CDFP, 'CDFP (400GE)'), + (TYPE_400GE_CFP8, 'CPF8 (400GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'), ) @@ -1109,6 +1141,8 @@ class InterfaceSpeedChoices(ChoiceSet): (25000000, '25 Gbps'), (40000000, '40 Gbps'), (100000000, '100 Gbps'), + (200000000, '200 Gbps'), + (400000000, '400 Gbps'), ] @@ -1223,6 +1257,10 @@ class PortTypeChoices(ChoiceSet): TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_APC = 'lsh-apc' + TYPE_LX5 = 'lx5' + TYPE_LX5_PC = 'lx5-pc' + TYPE_LX5_UPC = 'lx5-upc' + TYPE_LX5_APC = 'lx5-apc' TYPE_SPLICE = 'splice' TYPE_CS = 'cs' TYPE_SN = 'sn' @@ -1269,6 +1307,10 @@ class PortTypeChoices(ChoiceSet): (TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_APC, 'LSH/APC'), + (TYPE_LX5, 'LX.5'), + (TYPE_LX5_PC, 'LX.5/PC'), + (TYPE_LX5_UPC, 'LX.5/UPC'), + (TYPE_LX5_APC, 'LX.5/APC'), (TYPE_MPO, 'MPO'), (TYPE_MTRJ, 'MTRJ'), (TYPE_SC, 'SC'), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 80d7558c9..b3c065b5a 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, # RACK_U_HEIGHT_DEFAULT = 42 +RACK_U_HEIGHT_MAX = 100 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 1fc7dc832..26e527d31 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1084,10 +1084,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): def search(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(identifier=value.strip()) - ).distinct() + + qs_filter = Q(name__icontains=value) + try: + qs_filter |= Q(identifier=int(value)) + except ValueError: + pass + return queryset.filter(qs_filter).distinct() def _has_primary_ip(self, queryset, name, value): params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) @@ -1226,6 +1229,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label=_('Device (name)'), ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type', + queryset=DeviceType.objects.all(), + label=_('Device type (ID)'), + ) + device_type = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type__model', + queryset=DeviceType.objects.all(), + to_field_name='model', + label=_('Device type (model)'), + ) + device_role_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_role', + queryset=DeviceRole.objects.all(), + label=_('Device role (ID)'), + ) + device_role = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label=_('Device role (slug)'), + ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis', queryset=VirtualChassis.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6ed483c79..a16de0b75 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.models import User from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -1105,7 +1106,7 @@ class PowerPortBulkEditForm( (None, ('module', 'type', 'label', 'description', 'mark_connected')), ('Power', ('maximum_draw', 'allocated_draw')), ) - nullable_fields = ('module', 'label', 'description') + nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw') class PowerOutletBulkEditForm( @@ -1258,8 +1259,8 @@ class InterfaceBulkEditForm( ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', - 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' + 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', + 'tagged_vlans', 'vrf', 'wireless_lans' ) def __init__(self, *args, **kwargs): @@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm( break if site is not None: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Query for VLANs assigned to the same site and VLANs with no site assigned (null). + self.fields['untagged_vlan'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) + self.fields['tagged_vlans'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index cdb59e9eb..8c8c4f79a 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm): model = DeviceType fields = [ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', + 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', ] @@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags'] class DeviceRoleImportForm(NetBoxModelImportForm): @@ -1078,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm): model = content_type.model_class() try: - termination_object = model.objects.get(device=device, name=name) + if device.virtual_chassis and device.virtual_chassis.master == device and \ + model.objects.filter(device=device, name=name).count() == 0: + termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) + else: + termination_object = model.objects.get(device=device, name=name) if termination_object.cable is not None: raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") except ObjectDoesNotExist: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d31bba030..4edee6014 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Virtual Chassis') ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device type') + ) + device_role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Device role') + ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, query_params={ 'site_id': '$site_id', 'location_id': '$location_id', - 'virtual_chassis_id': '$virtual_chassis_id' + 'virtual_chassis_id': '$virtual_chassis_id', + 'device_type_id': '$device_type_id', + 'role_id': '$device_role_id' }, label=_('Device') ) @@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', - 'device_id', 'vdc_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) vdc_id = DynamicModelMultipleChoiceField( @@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Cable', ('cabled', 'occupied')), ) model = FrontPort @@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Cable', ('cabled', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'position')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) @@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 219216045..3c02e6e4e 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 d4c9e6ec3..f37edee0a 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: @@ -101,6 +104,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp choices=[], label=_('Rear ports'), help_text=_('Select one rear port assignment for each front port being created.'), + widget=forms.SelectMultiple(attrs={'size': 6}) ) # Override fieldsets from FrontPortTemplateForm to omit rear_port_position diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py index f3b8b696b..c97aa4c2b 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 000000000..44a0e95f2 --- /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/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py index dd21fddcf..f212aa21a 100644 --- a/netbox/dcim/migrations/0154_half_height_rack_units.py +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -18,6 +18,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='device', name='position', - field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]), ), ] diff --git a/netbox/dcim/migrations/0172_larger_power_draw_values.py b/netbox/dcim/migrations/0172_larger_power_draw_values.py new file mode 100644 index 000000000..729daf836 --- /dev/null +++ b/netbox/dcim/migrations/0172_larger_power_draw_values.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.9 on 2023-05-12 18:46 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0171_cabletermination_change_logging'), + ] + + operations = [ + migrations.AlterField( + model_name='powerport', + name='allocated_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerport', + name='maximum_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='allocated_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='maximum_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b0dc1677f..6a89655b2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel): choices=PowerPortTypeChoices, blank=True ) - maximum_draw = models.PositiveSmallIntegerField( + maximum_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], help_text=_("Maximum power draw (watts)") ) - allocated_draw = models.PositiveSmallIntegerField( + allocated_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 645fc5c5c..9f6837b92 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): blank=True, help_text=_('Physical port type') ) - maximum_draw = models.PositiveSmallIntegerField( + maximum_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], help_text=_("Maximum power draw (watts)") ) - allocated_draw = models.PositiveSmallIntegerField( + allocated_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 85a5d6870..fbc92e1fe 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin): super().clean() # U height must be divisible by 0.5 - if self.u_height % decimal.Decimal(0.5): + if decimal.Decimal(self.u_height) % decimal.Decimal(0.5): raise ValidationError({ 'u_height': "U height must be in increments of 0.5 rack units." }) @@ -568,7 +568,7 @@ class Device(PrimaryModel, ConfigContextModel): decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1), MaxValueValidator(99.5)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)], verbose_name='Position (U)', help_text=_('The lowest-numbered unit occupied by the device') ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e5412a3ab..d73c8e27b 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin): u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], help_text=_('Height in rack units') ) desc_units = models.BooleanField( @@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin): powerport.get_power_draw()['allocated'] for powerport in powerports ]) - return int(allocated_draw / available_power_total * 100) + return round(allocated_draw / available_power_total * 100, 1) @cached_property def total_weight(self): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 7ef08d2cc..a51872719 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs): Rack.objects.filter(location__in=locations).update(site=instance.site) Device.objects.filter(location__in=locations).update(site=instance.site) PowerPanel.objects.filter(location__in=locations).update(site=instance.site) + CableTermination.objects.filter(_location__in=locations).update(_site=instance.site) @receiver(post_save, sender=Rack) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index db2655d27..42b34e999 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/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3445b7e75..af15e1343 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2), ) DeviceType.objects.bulk_create(device_types) @@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_rack_fit(self): + """ + Check that creating multiple devices with overlapping position fails. + """ + device = Device.objects.first() + device_type = DeviceType.objects.all()[1] + data = [ + { + 'device_type': device_type.pk, + 'device_role': device.device_role.pk, + 'site': device.site.pk, + 'name': 'Test Device 7', + 'rack': device.rack.pk, + 'face': 'front', + 'position': 1 + }, + { + 'device_type': device_type.pk, + 'device_role': device.device_role.pk, + 'site': device.site.pk, + 'name': 'Test Device 8', + 'rack': device.rack.pk, + 'face': 'front', + 'position': 2 + } + ] + + self.add_permissions('dcim.add_device') + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class ModuleTest(APIViewTestCases.APIViewTestCase): model = Module diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 346b35005..3f9712f2a 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +class DeviceComponentFilterSetTests: + + def test_device_type(self): + device_types = DeviceType.objects.all()[:2] + params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_type': [device_types[0].model, device_types[1].model]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device_role(self): + device_role = DeviceRole.objects.all()[:2] + params = {'device_role_id': [device_role[0].pk, device_role[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_role': [device_role[0].slug, device_role[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Region.objects.all() filterset = RegionFilterSet @@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet @@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsoleServerPort.objects.all() filterset = ConsoleServerPortFilterSet @@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = PowerPort.objects.all() filterset = PowerPortFilterSet @@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = PowerOutlet.objects.all() filterset = PowerOutletFilterSet @@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): +class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet @@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) -class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() filterset = FrontPortFilterSet @@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = RearPort.objects.all() filterset = RearPortFilterSet @@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): +class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ModuleBay.objects.all() filterset = ModuleBayFilterSet @@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): +class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet @@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): ) Manufacturer.objects.bulk_create(manufacturers) - device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) regions = ( Region(name='Region 1', slug='region-1'), @@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'rack': [racks[0].name, racks[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device_type(self): + device_types = DeviceType.objects.all()[:2] + params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'device_type': [device_types[0].model, device_types[1].model]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_device_role(self): + device_role = DeviceRole.objects.all()[:2] + params = {'device_role_id': [device_role[0].pk, device_role[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'device_role': [device_role[0].slug, device_role[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 44e6ef2a9..c0cfca2e7 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2907,6 +2907,7 @@ class CableTestCase( manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + vc = VirtualChassis.objects.create(name='Virtual Chassis') devices = ( Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), @@ -2916,6 +2917,10 @@ class CableTestCase( ) Device.objects.bulk_create(devices) + vc.members.set((devices[0], devices[1], devices[2])) + vc.master = devices[0] + vc.save() + interfaces = ( Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), @@ -2929,6 +2934,10 @@ class CableTestCase( Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -2961,6 +2970,8 @@ class CableTestCase( "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", + "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4", + "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5", ) cls.csv_update_data = ( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 558b9704b..7eda07eb4 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 @@ -20,6 +21,7 @@ from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.tables import InterfaceVLANTable from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model @@ -44,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): @@ -267,6 +278,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable +@register_model_view(Region, 'contacts') +class RegionContactsView(ObjectContactsView): + queryset = Region.objects.all() + + # # Site groups # @@ -342,6 +358,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView): table = tables.SiteGroupTable +@register_model_view(SiteGroup, 'contacts') +class SiteGroupContactsView(ObjectContactsView): + queryset = SiteGroup.objects.all() + + # # Sites # @@ -411,6 +432,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView): table = tables.SiteTable +@register_model_view(Site, 'contacts') +class SiteContactsView(ObjectContactsView): + queryset = Site.objects.all() + + # # Locations # @@ -491,6 +517,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView): table = tables.LocationTable +@register_model_view(Location, 'contacts') +class LocationContactsView(ObjectContactsView): + queryset = Location.objects.all() + + # # Rack roles # @@ -670,6 +701,26 @@ class RackRackReservationsView(generic.ObjectChildrenView): return parent.reservations.restrict(request.user, 'view') +@register_model_view(Rack, 'nonracked_devices', 'nonracked-devices') +class RackNonRackedView(generic.ObjectChildrenView): + queryset = Rack.objects.all() + child_model = Device + table = tables.DeviceTable + filterset = filtersets.DeviceFilterSet + template_name = 'dcim/rack/non_racked_devices.html' + tab = ViewTab( + label=_('Non-Racked Devices'), + badge=lambda obj: obj.devices.filter(rack=obj, position__isnull=True, parent_bay__isnull=True).count(), + weight=500, + permission='dcim.view_device', + ) + + def get_children(self, request, parent): + return parent.devices.restrict(request.user, 'view').filter( + rack=parent, position__isnull=True, parent_bay__isnull=True + ) + + @register_model_view(Rack, 'edit') class RackEditView(generic.ObjectEditView): queryset = Rack.objects.all() @@ -700,6 +751,11 @@ class RackBulkDeleteView(generic.BulkDeleteView): table = tables.RackTable +@register_model_view(Rack, 'contacts') +class RackContactsView(ObjectContactsView): + queryset = Rack.objects.all() + + # # Rack reservations # @@ -834,6 +890,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): table = tables.ManufacturerTable +@register_model_view(Manufacturer, 'contacts') +class ManufacturerContactsView(ObjectContactsView): + queryset = Manufacturer.objects.all() + + # # Device types # @@ -1914,6 +1975,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(), @@ -1929,6 +1991,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(), @@ -1940,6 +2003,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 @@ -2048,6 +2112,11 @@ class DeviceBulkRenameView(generic.BulkRenameView): table = tables.DeviceTable +@register_model_view(Device, 'contacts') +class DeviceContactsView(ObjectContactsView): + queryset = Device.objects.all() + + # # Modules # @@ -2117,7 +2186,6 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsolePort) @@ -2181,7 +2249,6 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsoleServerPort) @@ -2245,7 +2312,6 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerPort) @@ -2309,7 +2375,6 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerOutlet) @@ -2373,7 +2438,6 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(Interface) @@ -2483,7 +2547,6 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(FrontPort) @@ -2547,7 +2610,6 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(RearPort) @@ -2611,7 +2673,6 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ModuleBay) @@ -2667,7 +2728,6 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(DeviceBay) @@ -2792,7 +2852,6 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(InventoryItem) @@ -3065,6 +3124,19 @@ class CableEditView(generic.ObjectEditView): return obj + def get_extra_addanother_params(self, request): + + params = { + 'a_terminations_type': request.GET.get('a_terminations_type'), + 'b_terminations_type': request.GET.get('b_terminations_type') + } + + for key in request.POST: + if 'device' in key or 'power_panel' in key or 'circuit' in key: + params.update({key: request.POST.get(key)}) + + return params + @register_model_view(Cable, 'delete') class CableDeleteView(generic.ObjectDeleteView): @@ -3429,6 +3501,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPanelTable +@register_model_view(PowerPanel, 'contacts') +class PowerPanelContactsView(ObjectContactsView): + queryset = PowerPanel.objects.all() + + # # Power feeds # diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 04a67b521..6d1b14370 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin): 'fields': ('ALLOWED_URL_SCHEMES',), }), ('Banners', { - 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'), 'classes': ('monospace',), }), ('Pagination', { diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3f796d7f8..a7792803b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,6 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.generics import RetrieveUpdateDestroyAPIView -from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView @@ -303,7 +302,7 @@ class ScriptViewSet(ViewSet): # Attach Job objects to each script (if any) for script in script_list: - script.result = results.get(script.name, None) + script.result = results.get(script.class_name, None) serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request}) @@ -314,7 +313,7 @@ class ScriptViewSet(ViewSet): object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') script.result = Job.objects.filter( object_type=object_type, - name=script.name, + name=script.class_name, status__in=JobStatusChoices.TERMINAL_STATE_CHOICES ).first() serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) @@ -368,7 +367,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): Retrieve a list of recent changes. """ metadata_class = ContentTypeMetadata - queryset = ObjectChange.objects.prefetch_related('user') + queryset = ObjectChange.objects.valid_models().prefetch_related('user') serializer_class = serializers.ObjectChangeSerializer filterset_class = filtersets.ObjectChangeFilterSet @@ -381,7 +380,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet): """ Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects. """ - permission_classes = (IsAuthenticated,) + permission_classes = [IsAuthenticatedOrLoginNotRequired] queryset = ContentType.objects.order_by('app_label', 'model') serializer_class = serializers.ContentTypeSerializer filterset_class = filtersets.ContentTypeFilterSet diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index e10516c4c..63bdbf7db 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet): VISIBILITY_READ_WRITE = 'read-write' VISIBILITY_READ_ONLY = 'read-only' VISIBILITY_HIDDEN = 'hidden' + VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset' CHOICES = ( (VISIBILITY_READ_WRITE, 'Read/Write'), (VISIBILITY_READ_ONLY, 'Read-only'), (VISIBILITY_HIDDEN, 'Hidden'), + (VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'), ) @@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Create'), - (ACTION_UPDATE, 'Update'), - (ACTION_DELETE, 'Delete'), + (ACTION_CREATE, 'Create', 'green'), + (ACTION_UPDATE, 'Update', 'blue'), + (ACTION_DELETE, 'Delete', 'red'), ) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index c6744e524..db054149e 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -65,8 +65,14 @@ class Condition: """ Evaluate the provided data to determine whether it matches the condition. """ + def _get(obj, key): + if isinstance(obj, list): + return [dict.get(i, key) for i in obj] + + return dict.get(obj, key) + try: - value = functools.reduce(dict.get, self.attr.split('.'), data) + value = functools.reduce(_get, self.attr.split('.'), data) except TypeError: # Invalid key path value = None diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 69d1cc36d..31e7cb2d3 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -11,14 +11,14 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import Q from django.template.loader import render_to_string -from django.urls import NoReverseMatch, reverse +from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ from extras.utils import FeatureQuery from utilities.forms import BootstrapMixin from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, get_viewname +from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname from .utils import register_widget __all__ = ( @@ -35,7 +35,8 @@ def get_content_type_labels(): return [ (content_type_identifier(ct), content_type_name(ct)) for ct in ContentType.objects.filter( - FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') + FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') | + Q(app_label='extras', model='configcontext') ).order_by('app_label', 'model') ] @@ -148,7 +149,7 @@ class ObjectCountsWidget(DashboardWidget): filters = forms.JSONField( required=False, label='Object filters', - help_text=_("Only objects matching the specified filters will be counted") + help_text=_("Filters to apply when counting the number of objects") ) def clean_filters(self): @@ -157,13 +158,6 @@ class ObjectCountsWidget(DashboardWidget): dict(data) except TypeError: raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.") - for model in get_models_from_content_types(self.cleaned_data.get('models')): - try: - # Validate the filters by creating a QuerySet - model.objects.filter(**data).none() - except Exception: - model_name = model._meta.verbose_name_plural - raise forms.ValidationError(f"Invalid filter specification for {model_name}.") return data def render(self, request): @@ -171,13 +165,18 @@ class ObjectCountsWidget(DashboardWidget): for model in get_models_from_content_types(self.config['models']): permission = get_permission_for_model(model, 'view') if request.user.has_perm(permission): + url = reverse(get_viewname(model, 'list')) qs = model.objects.restrict(request.user, 'view') + # Apply any specified filters if filters := self.config.get('filters'): - qs = qs.filter(**filters) + params = dict_to_querydict(filters) + filterset = getattr(resolve(url).func.view_class, 'filterset', None) + qs = filterset(params, qs).qs + url = f'{url}?{params.urlencode()}' object_count = qs.count - counts.append((model, object_count)) + counts.append((model, object_count, url)) else: - counts.append((model, None)) + counts.append((model, None, None)) return render_to_string(self.template_name, { 'counts': counts, diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 05febaa6f..19a7878e1 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form): self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data - - @property - def requires_input(self): - """ - A boolean indicating whether the form requires user input (ignore the built-in fields). - """ - return bool(len(self.fields) > 3) diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index 77fe2301e..a8d89c943 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -7,12 +7,14 @@ class Empty(Lookup): Filter on whether a string is empty. """ lookup_name = 'empty' + prepare_rhs = False - def as_sql(self, qn, connection): - lhs, lhs_params = self.process_lhs(qn, connection) - rhs, rhs_params = self.process_rhs(qn, connection) - params = lhs_params + rhs_params - return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params + def as_sql(self, compiler, connection): + sql, params = compiler.compile(self.lhs) + if self.rhs: + return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params + else: + return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params class NetContainsOrEquals(Lookup): diff --git a/netbox/extras/migrations/0066_customfield_name_validation.py b/netbox/extras/migrations/0066_customfield_name_validation.py index 7a768c10c..3d2c51399 100644 --- a/netbox/extras/migrations/0066_customfield_name_validation.py +++ b/netbox/extras/migrations/0066_customfield_name_validation.py @@ -13,6 +13,22 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='customfield', name='name', - field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]), + field=models.CharField( + max_length=50, + unique=True, + validators=[ + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + message='Only alphanumeric characters and underscores are allowed.', + regex='^[a-z0-9_]+$', + ), + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + inverse_match=True, + message='Double underscores are not permitted in custom field names.', + regex=r'__', + ), + ], + ), ), ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index e2b118b84..2cb53ed01 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,7 +5,7 @@ from django.db import models from django.urls import reverse from extras.choices import * -from utilities.querysets import RestrictedQuerySet +from ..querysets import ObjectChangeQuerySet __all__ = ( 'ObjectChange', @@ -82,7 +82,7 @@ class ObjectChange(models.Model): null=True ) - objects = RestrictedQuerySet.as_manager() + objects = ObjectChangeQuerySet.as_manager() class Meta: ordering = ['-time'] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 439d15edc..be3540f08 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): message="Only alphanumeric characters and underscores are allowed.", flags=re.IGNORECASE ), + RegexValidator( + regex=r'__', + message="Double underscores are not permitted in custom field names.", + flags=re.IGNORECASE, + inverse_match=True + ), ) ) label = models.CharField( diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 16e4fb577..c76a5a76f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse, QueryDict +from django.http import HttpResponse from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -26,7 +26,7 @@ from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) from utilities.querysets import RestrictedQuerySet -from utilities.utils import clean_html, render_jinja2 +from utilities.utils import clean_html, dict_to_querydict, render_jinja2 __all__ = ( 'ConfigRevision', @@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): :param context: The context passed to Jinja2 """ - text = render_jinja2(self.link_text, context) + text = render_jinja2(self.link_text, context).strip() if not text: return {} - link = render_jinja2(self.link_url, context) + link = render_jinja2(self.link_url, context).strip() link_target = ' target="_blank"' if self.new_window else '' # Sanitize link text @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;') # Verify link scheme is allowed result = urllib.parse.urlparse(link) @@ -462,8 +462,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): @property def url_params(self): - qd = QueryDict(mutable=True) - qd.update(self.parameters) + qd = dict_to_querydict(self.parameters) return qd.urlencode() diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py index aaa785696..f1e336df5 100644 --- a/netbox/extras/models/reports.py +++ b/netbox/extras/models/reports.py @@ -1,7 +1,7 @@ import inspect +import logging from functools import cached_property -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse @@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from .mixins import PythonModuleMixin +logger = logging.getLogger('netbox.reports') + __all__ = ( 'Report', 'ReportModule', @@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile): try: module = self.get_module() - except ImportError: + except (ImportError, SyntaxError) as e: + logger.error(f"Unable to load report module {self.name}, exception: {e}") return {} reports = {} ordered = getattr(module, 'report_order', []) diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b46d6a7bc..6d86e0dfe 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -112,3 +112,6 @@ class StagedChange(ChangeLoggedModel): instance = self.model.objects.get(pk=self.object_id) logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') instance.delete() + + def get_action_color(self): + return ChangeActionChoices.colors.get(self.action) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 83c7a7bb0..8736a3197 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 000000000..c260f156d --- /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/querysets.py b/netbox/extras/querysets.py index 2b97af0fb..7b71fa656 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,5 +1,8 @@ +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 @@ -151,3 +154,20 @@ 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/reports.py b/netbox/extras/reports.py index 8f3af2a09..6af81a9d9 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/scripts.py b/netbox/extras/scripts.py index cebc57af4..9fa31db31 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -366,7 +366,7 @@ class BaseScript: if self.fieldsets: fieldsets.extend(self.fieldsets) else: - fields = (name for name, _ in self._get_vars().items()) + fields = list(name for name, _ in self._get_vars().items()) fieldsets.append(('Script Data', fields)) # Append the default fieldset if defined in the Meta class @@ -390,6 +390,11 @@ class BaseScript: # Set initial "commit" checkbox state based on the script's Meta parameter form.fields['_commit'].initial = self.commit_default + # Hide fields if scheduling has been disabled + if not self.scheduling_enabled: + form.fields['_schedule_at'].widget = forms.HiddenInput() + form.fields['_interval'].widget = forms.HiddenInput() + return form # Logging diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e6d014302..9e4924532 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -22,6 +22,14 @@ __all__ = ( 'WebhookTable', ) +IMAGEATTACHMENT_IMAGE = ''' +{% if record.image %} + {{ record }} +{% else %} + — +{% endif %} +''' + class CustomFieldTable(NetBoxTable): name = tables.Column( @@ -73,6 +81,7 @@ class ExportTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -95,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable): parent = tables.Column( linkify=True ) + image = tables.TemplateColumn( + template_code=IMAGEATTACHMENT_IMAGE, + ) size = tables.Column( orderable=False, verbose_name='Size (bytes)' @@ -218,6 +230,7 @@ class ConfigContextTable(NetBoxTable): verbose_name='Active' ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -242,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) tags = columns.TagColumn( diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b59481a36..086c8e246 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,7 +8,6 @@ from rest_framework import status from core.choices import ManagedFileRootPathChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site -from extras.api.views import ReportViewSet, ScriptViewSet from extras.models import * from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar @@ -579,6 +578,7 @@ class ReportTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_report() method to return our test Report above + from extras.api.views import ReportViewSet ReportViewSet._get_report = self.get_test_report def test_get_report(self): @@ -621,6 +621,7 @@ class ScriptTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_script() method to return our test Script above + from extras.api.views import ScriptViewSet ScriptViewSet._get_script = self.get_test_script def test_get_script(self): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 6a3a3d074..3fd0dc83e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -29,6 +29,17 @@ class CustomFieldTest(TestCase): cls.object_type = ContentType.objects.get_for_model(Site) + def test_invalid_name(self): + """ + Try creating a CustomField with an invalid name. + """ + with self.assertRaises(ValidationError): + # Invalid character + CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + with self.assertRaises(ValidationError): + # Double underscores not permitted + CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + def test_text_field(self): value = 'Foobar!' diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index cb7629ad2..42dde43fd 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 19264dabb..ef7637765 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/extras/views.py b/netbox/extras/views.py index 6cbadf09d..6ba63ab58 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): # class ObjectChangeListView(generic.ObjectListView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() filterset = filtersets.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable @@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView): @register_model_view(ObjectChange) class ObjectChangeView(generic.ObjectView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() def get_extra_context(self, request, instance): - related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( + related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( request_id=instance.request_id ).exclude( pk=instance.pk @@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView): orderable=False ) - objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( + objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( changed_object_type=instance.changed_object_type, changed_object_id=instance.changed_object_id, ) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 23702949a..1fc869ee8 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -9,6 +9,7 @@ from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry from utilities.api import get_serializer_for_model +from utilities.rqworker import get_rq_retry from utilities.utils import serialize_object from .choices import * from .models import Webhook @@ -116,5 +117,6 @@ def flush_webhooks(queue): snapshots=data['snapshots'], timestamp=str(timezone.now()), username=data['username'], - request_id=data['request_id'] + request_id=data['request_id'], + retry=get_rq_retry() ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..1501f16dc 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer): scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) + utilization = serializers.CharField(read_only=True) class Meta: model = VLANGroup fields = [ 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' ] validators = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..feffc3ff2 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction +from django.db.models import F +from django.db.models.functions import Round from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema @@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet): class VLANGroupViewSet(NetBoxModelViewSet): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ).prefetch_related('tags') + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet @@ -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 a128b6acc..9b57cb273 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -4,6 +4,8 @@ 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 drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except (AddrFormatError, ValueError): return queryset.none() + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -588,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 @@ -659,6 +666,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset return queryset.filter(address__net_mask_length=value) + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -702,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( @@ -727,6 +747,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): Q(name__icontains=value) ) + @extend_schema_field(OpenApiTypes.STR) def filter_related_ip(self, queryset, name, value): """ Filter by VRF & prefix of assigned IP addresses. @@ -941,9 +962,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): pass return queryset.filter(qs_filter) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_virtualmachine(self, queryset, name, value): return queryset.get_for_virtualmachine(value) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index fd0b315a0..3bce26249 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -9,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__ = ( @@ -40,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): @@ -181,16 +198,31 @@ class PrefixImportForm(NetBoxModelImportForm): def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) - if data: + if not data: + return - # Limit VLAN queryset by assigned site and/or group (if specified) - params = {} - if data.get('site'): - params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') - if data.get('vlan_group'): - params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') - if params: - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + site = data.get('site') + vlan_group = data.get('vlan_group') + + # Limit VLAN queryset by assigned site and/or group (if specified) + query = Q() + + if site: + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}": site + }) + # Don't Forget to include VLANs without a site in the filter + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}__isnull": True + }) + + if vlan_group: + query &= Q(**{ + f"group__{self.fields['vlan_group'].to_field_name}": vlan_group + }) + + queryset = self.fields['vlan'].queryset.filter(query) + self.fields['vlan'].queryset = queryset class IPRangeImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 53fecfe2f..f00082863 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/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index cf8117bf7..a3c218fc9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + selector=True, label=_('VLAN'), - query_params={ - 'site_id': '$site', - } ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ): self.initial['primary_for_parent'] = True + # Disable object assignment fields if the IP address is designated as primary + if self.initial.get('primary_for_parent'): + self.fields['interface'].disabled = True + self.fields['vminterface'].disabled = True + self.fields['fhrpgroup'].disabled = True + def clean(self): super().clean() @@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): selected_objects[1]: "An IP address can only be assigned to a single object." }) elif selected_objects: - self.instance.assigned_object = self.cleaned_data[selected_objects[0]] + assigned_object = self.cleaned_data[selected_objects[0]] + if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + raise ValidationError( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + self.instance.assigned_object = assigned_object else: self.instance.assigned_object = None @@ -351,6 +360,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) + # Do not allow assigning a network ID or broadcast address to an interface. + if interface and (address := self.cleaned_data.get('address')): + if address.ip == address.network: + msg = f"{address} is a network ID, which may not be assigned to an interface." + if address.version == 4 and address.prefixlen not in (31, 32): + raise ValidationError(msg) + if address.version == 6 and address.prefixlen not in (127, 128): + raise ValidationError(msg) + if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): + msg = f"{address} is a broadcast address, which may not be assigned to an interface." + raise ValidationError(msg) + def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) @@ -358,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): interface = self.instance.assigned_object if type(interface) in (Interface, VMInterface): parent = interface.parent_object + parent.snapshot() if self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: parent.primary_ip4 = ipaddress diff --git a/netbox/ipam/graphql/gfk_mixins.py b/netbox/ipam/graphql/gfk_mixins.py index 31742c4a4..01c79690a 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/models/asns.py b/netbox/ipam/models/asns.py index a07cbb789..6c0b5231b 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from ipam.fields import ASNField +from ipam.querysets import ASNRangeQuerySet from netbox.models import OrganizationalModel, PrimaryModel __all__ = ( @@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel): null=True ) + objects = ASNRangeQuerySet.as_manager() + class Meta: ordering = ('name',) verbose_name = 'ASN range' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 015f9220c..00dcf8422 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): Return all available IPs within this prefix as an IPSet. """ if self.mark_utilized: - return list() + return netaddr.IPSet() prefix = netaddr.IPSet(self.prefix) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7d4777da9..da504ded2 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext as _ from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.querysets import VLANQuerySet +from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface @@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel): help_text=_('Highest permissible ID of a child VLAN') ) + objects = VLANGroupQuerySet.as_manager() + class Meta: ordering = ('name', 'pk') # Name may be non-unique constraints = ( diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 9f4463f61..39da0c3a2 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,8 +1,34 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Count, F, OuterRef, Q, Subquery, Value from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from utilities.querysets import RestrictedQuerySet +from utilities.utils import count_related + +__all__ = ( + 'ASNRangeQuerySet', + 'PrefixQuerySet', + 'VLANQuerySet', +) + + +class ASNRangeQuerySet(RestrictedQuerySet): + + def annotate_asn_counts(self): + """ + Annotate the number of ASNs which appear within each range. + """ + from .models import ASN + + # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value + # that we can use to count ASNs and return a single value per ASNRange. + asns = ASN.objects.filter( + asn__gte=OuterRef('start'), + asn__lte=OuterRef('end') + ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c') + + return self.annotate(asn_count=Subquery(asns)) class PrefixQuerySet(RestrictedQuerySet): @@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet): ) +class VLANGroupQuerySet(RestrictedQuerySet): + + def annotate_utilization(self): + from .models import VLAN + + return self.annotate( + vlan_count=count_related(VLAN, 'group'), + utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2) + ) + + class VLANQuerySet(RestrictedQuerySet): def get_for_device(self, device): diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py index 511e914ec..356f2fc17 100644 --- a/netbox/ipam/tables/asn.py +++ b/netbox/ipam/tables/asn.py @@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:asnrange_list' ) - asn_count = columns.LinkedCountColumn( - viewname='ipam:asn_list', - url_params={'asn_id': 'pk'}, - verbose_name=_('ASN Count') + asn_count = tables.Column( + verbose_name=_('ASNs') ) class Meta(NetBoxTable.Meta): @@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Provider Count') ) sites = columns.ManyToManyColumn( - linkify_item=True + linkify_item=True, + verbose_name=_('Sites') ) comments = columns.MarkdownColumn() tags = columns.TagColumn( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 86d1a3775..aff090f3a 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -19,14 +19,22 @@ __all__ = ( AVAILABLE_LABEL = mark_safe('Available') +AGGREGATE_COPY_BUTTON = """ +{% copy_content record.pk prefix="aggregate_" %} +""" + PREFIX_LINK = """ {% if record.pk %} - {{ record.prefix }} + {{ record.prefix }} {% else %} {{ record.prefix }} {% endif %} """ +PREFIX_COPY_BUTTON = """ +{% copy_content record.pk prefix="prefix_" %} +""" + PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} {% if record.depth %} @@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """ IPADDRESS_LINK = """ {% if record.pk %} - {{ record.address }} + {{ record.address }} {% elif perms.ipam.add_ipaddress %} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} @@ -48,6 +56,10 @@ IPADDRESS_LINK = """ {% endif %} """ +IPADDRESS_COPY_BUTTON = """ +{% copy_content record.pk prefix="ipaddress_" %} +""" + IPADDRESS_ASSIGN_LINK = """ {{ record }} """ @@ -99,7 +111,11 @@ class RIRTable(NetBoxTable): class AggregateTable(TenancyColumnsMixin, NetBoxTable): prefix = tables.Column( linkify=True, - verbose_name='Aggregate' + verbose_name='Aggregate', + attrs={ + # Allow the aggregate to be copied to the clipboard + 'a': {'id': lambda record: f"aggregate_{record.pk}"} + } ) date_added = tables.DateColumn( format="Y-m-d", @@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:aggregate_list' ) + actions = columns.ActionsColumn( + extra_buttons=AGGREGATE_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Aggregate @@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:prefix_list' ) + actions = columns.ActionsColumn( + extra_buttons=PREFIX_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Prefix @@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) + actions = columns.ActionsColumn( + extra_buttons=IPADDRESS_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 6fa2cd2da..5d9828531 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + utilization = columns.UtilizationColumn( + orderable=False, + verbose_name='Utilization' + ) tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) @@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'created', 'last_updated', 'actions', + 'tags', 'created', 'last_updated', 'actions', 'utilization', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description') # diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 3d9a66567..0ae7544ab 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/tests/test_views.py b/netbox/ipam/tests/test_views.py index 44af9eae2..c9128c0f6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ + IMPORT_DATA = """ +prefix: 10.1.1.0/24 +status: active +vlan: 101 +site: Site 1 +""" + # Note, a site is not tied to the VLAN to verify the fix for #12622 + VLAN.objects.create(vid=101, name='VLAN101') + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.1.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 101) + self.assertEqual(prefix.site.name, "Site 1") + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import_with_vlan_group(self): + """ + This test covers a unique import edge case where VLAN group is specified during the import. + """ + IMPORT_DATA = """ +prefix: 10.1.2.0/24 +status: active +vlan: 102 +site: Site 1 +vlan_group: Group 1 +""" + vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1")) + VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group) + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.2.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 102) + self.assertEqual(prefix.site.name, "Site 1") + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 93a40e5a0..262fd8d46 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 6b19b502d..d8e4d8b47 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Prefetch +from django.db.models import F, Prefetch from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -9,6 +10,7 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet @@ -197,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView): # class ASNRangeListView(generic.ObjectListView): - queryset = ASNRange.objects.all() + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet filterset_form = forms.ASNRangeFilterForm table = tables.ASNRangeTable @@ -214,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(), @@ -246,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView): class ASNRangeBulkEditView(generic.BulkEditView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable form = forms.ASNRangeBulkEditForm class ASNRangeBulkDeleteView(generic.BulkDeleteView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable @@ -818,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] @@ -868,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(), @@ -885,9 +882,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): # class VLANGroupListView(generic.ObjectListView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable @@ -895,7 +890,7 @@ class VLANGroupListView(generic.ObjectListView): @register_model_view(VLANGroup) class VLANGroupView(generic.ObjectView): - queryset = VLANGroup.objects.all() + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') def get_extra_context(self, request, instance): related_models = ( @@ -937,18 +932,14 @@ class VLANGroupBulkImportView(generic.BulkImportView): class VLANGroupBulkEditView(generic.BulkEditView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable form = forms.VLANGroupBulkEditForm class VLANGroupBulkDeleteView(generic.BulkDeleteView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable @@ -971,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), @@ -1085,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(), @@ -1103,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(), @@ -1300,6 +1290,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable +@register_model_view(L2VPN, 'contacts') +class L2VPNContactsView(ObjectContactsView): + queryset = L2VPN.objects.all() + + # # L2VPN terminations # diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 814ca1ed6..f0bd5fd27 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication): user = token.user # When LDAP authentication is active try to load user data from LDAP directory - if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': + if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 5c55697ff..97f690762 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/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 8b629bbc6..fde486fe9 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model __all__ = ( 'BriefModeMixin', + 'BulkDestroyModelMixin', 'BulkUpdateModelMixin', 'CustomFieldsMixin', 'ExportTemplatesMixin', - 'BulkDestroyModelMixin', 'ObjectValidationMixin', + 'SequentialBulkCreatesMixin', ) @@ -94,6 +95,30 @@ class ExportTemplatesMixin: return super().list(request, *args, **kwargs) +class SequentialBulkCreatesMixin: + """ + Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation + which depends on the evaluation of existing objects (such as checking for free space within a rack) functions + appropriately. + """ + @transaction.atomic + def create(self, request, *args, **kwargs): + if not isinstance(request.data, list): + # Creating a single object + return super().create(request, *args, **kwargs) + + return_data = [] + for data in request.data: + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return_data.append(serializer.data) + + headers = self.get_success_headers(serializer.data) + + return Response(return_data, status=status.HTTP_201_CREATED, headers=headers) + + class BulkUpdateModelMixin: """ Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 798cb80e2..61dfe2fdb 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend): try: group_list.append(Group.objects.get(name=name)) except Group.DoesNotExist: - logging.error( - f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS: + group_list.append(Group.objects.create(name=name)) + else: + logging.error( + f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") if group_list: user.groups.set(group_list) logger.debug( diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 2bfa234f0..9c613217c 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -28,6 +28,17 @@ PARAMS = ( ), }, ), + ConfigParam( + name='BANNER_MAINTENANCE', + label=_('Maintenance banner'), + default='NetBox is currently in maintenance mode. Functionality may be limited.', + description=_('Additional content to display when in maintenance mode'), + field_kwargs={ + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), + }, + ), ConfigParam( name='BANNER_TOP', label=_('Top banner'), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index a0c1edee8..9a2385c45 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( + filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) + new_filter = filter_cls( field_name=field_name, lookup_expr=lookup_expr, label=existing_filter.label, @@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet): return filters + @classmethod + def filter_for_lookup(cls, field, lookup_type): + + if lookup_type == 'empty': + return django_filters.BooleanFilter, {} + + return super().filter_for_lookup(field, lookup_type) + class ChangeLoggedModelFilterSet(BaseFilterSet): """ diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f..b406ab04e 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/middleware.py b/netbox/netbox/middleware.py index f9faa9c5d..18f350fd7 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -3,19 +3,21 @@ import uuid from urllib import parse from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.core.exceptions import ImproperlyConfigured -from django.db import ProgrammingError +from django.db import connection, ProgrammingError +from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect from extras.context_managers import change_logging -from netbox.config import clear_config +from netbox.config import clear_config, get_config from netbox.views import handler_500 from utilities.api import is_api_request, rest_api_server_error __all__ = ( 'CoreMiddleware', + 'MaintenanceModeMiddleware', 'RemoteUserMiddleware', ) @@ -47,6 +49,9 @@ class CoreMiddleware: # Attach the unique request ID as an HTTP header. response['X-Request-ID'] = request.id + # Enable the Vary header to help with caching of HTMX responses + response['Vary'] = 'HX-Request' + # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5'). if is_api_request(request): response['API-Version'] = settings.REST_FRAMEWORK_VERSION @@ -166,3 +171,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): groups = [] logger.debug(f"Groups are {groups}") return groups + + +class MaintenanceModeMiddleware: + """ + Middleware that checks if the application is in maintenance mode + and restricts write-related operations to the database. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if get_config().MAINTENANCE_MODE: + self._set_session_type( + allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS) + ) + + return self.get_response(request) + + @staticmethod + def _set_session_type(allow_write): + """ + Prevent any write-related database operations. + + Args: + allow_write (bool): If True, write operations will be permitted. + """ + with connection.cursor() as cursor: + mode = 'READ WRITE' if allow_write else 'READ ONLY' + cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};') + + def process_exception(self, request, exception): + """ + Prevent any write-related database operations if an exception is raised. + """ + if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError): + error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \ + 'operations. Please try again later.' + + if is_api_request(request): + return rest_api_server_error(request, error=error_message) + + messages.error(request, error_message) + return HttpResponseRedirect(request.path_info) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6d82e2a2b..1e55ec2a3 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - value = self.custom_field_data.get(field.name) + + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden: + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: + continue + data[field] = field.deserialize(value) return data @@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model): for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) + if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + continue value = cf.deserialize(value) groups[cf.group_name][cf] = value @@ -436,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/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6e5bcfc23..1379beba5 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactrole', _('Contact Roles')), - get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]), + get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']), ), ), ), @@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu( label=_('Connections'), items=( get_model_item('dcim', 'cable', _('Cables'), actions=['import']), - get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links')), MenuItem( link='dcim:interface_connections_list', link_text=_('Interface Connections'), @@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu( MenuItem( link='extras:report_list', link_text=_('Reports'), - permissions=['extras.view_report'] + permissions=['extras.view_report'], + buttons=get_model_buttons('extras', "reportmodule", actions=['add']) ), MenuItem( link='extras:script_list', link_text=_('Scripts'), - permissions=['extras.view_script'] + permissions=['extras.view_script'], + buttons=get_model_buttons('extras', "scriptmodule", actions=['add']) ), ), ), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3f3f96736..aace6745a 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.2-dev' +VERSION = '3.5.9-dev' # Hostname HOSTNAME = platform.node() @@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) +REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) @@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) +RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60) +RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) @@ -382,6 +385,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.CoreMiddleware', + 'netbox.middleware.MaintenanceModeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', ] @@ -457,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 = ( @@ -476,6 +478,11 @@ AUTH_EXEMPT_PATHS = ( f'/{BASE_PATH}metrics', ) +# All URLs starting with a string listed here are exempt from maintenance mode enforcement +MAINTENANCE_EXEMPT_PATHS = ( + f'/{BASE_PATH}admin/', +) + SERIALIZATION_MODULES = { 'json': 'utilities.serializers.json', } diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 66ee787a8..399b3c184 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -234,8 +234,12 @@ class ActionsColumn(tables.Column): return '' model = table.Meta.model - request = getattr(table, 'context', {}).get('request') - url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' + if request := getattr(table, 'context', {}).get('request'): + return_url = request.GET.get('return_url', request.get_full_path()) + url_appendix = f'?return_url={quote(return_url)}' + else: + url_appendix = '' + html = '' # Compile actions menu @@ -500,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 839d85996..975311e4a 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,13 +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 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) - elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + 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.{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.{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/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 790cb4bd8..4e46996b5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase): list(new_user.groups.all()) ) + @override_settings( + REMOTE_AUTH_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_USER=True, + REMOTE_AUTH_GROUP_SYNC_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_GROUPS=True, + LOGIN_REQUIRED=True, + ) + def test_remote_auth_remote_groups_autocreate(self): + """ + Test enabling remote authentication with group sync and autocreate + enabled with the default configuration. + """ + headers = { + "HTTP_REMOTE_USER": "remoteuser2", + "HTTP_REMOTE_USER_GROUP": "Group 1|Group 2", + } + + self.assertTrue(settings.REMOTE_AUTH_ENABLED) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS) + self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED) + self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER") + self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP") + self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|") + + groups = ( + Group(name="Group 1"), + Group(name="Group 2"), + ) + + response = self.client.get(reverse("home"), follow=True, **headers) + self.assertEqual(response.status_code, 200) + + new_user = User.objects.get(username="remoteuser2") + self.assertEqual( + int(self.client.session.get("_auth_user_id")), + new_user.pk, + msg="Authentication failed", + ) + self.assertListEqual( + [group.name for group in groups], + [group.name for group in list(new_user.groups.all())], + ) + @override_settings( REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_AUTO_CREATE_USER=True, diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index c74c67cef..a81d45cb5 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/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e66e79a7a..35caa31b3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, m2m_field in m2m_fields.items(): if name in form.nullable_fields and name in nullified_fields: getattr(obj, name).clear() - else: + elif form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Add/remove tags diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 1ba789cf1..99d8ff540 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-dark.css b/netbox/project-static/dist/netbox-dark.css index 11110069e..2aa24b72c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 8a3c83af9..ffdd83285 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index ef2682e0a..b492e4d1d 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 8caaaa9a0..84bfecae3 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0201e7bf8..7f2400ed2 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index f10b5b7ac..98e1a5c60 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.5", "flatpickr": "4.6.13", "gridstack": "^7.2.3", + "html-entities": "^2.3.3", "htmx.org": "^1.8.0", "just-debounce-it": "^3.1.1", "query-string": "^7.1.1", diff --git a/netbox/project-static/src/buttons/selectAll.ts b/netbox/project-static/src/buttons/selectAll.ts index 64b98d390..f40520e26 100644 --- a/netbox/project-static/src/buttons/selectAll.ts +++ b/netbox/project-static/src/buttons/selectAll.ts @@ -1,4 +1,4 @@ -import { getElement, getElements, findFirstAdjacent } from '../util'; +import { getElements, findFirstAdjacent } from '../util'; /** * If any PK checkbox is checked, uncheck the select all table checkbox and the select all @@ -63,29 +63,6 @@ function handleSelectAllToggle(event: Event): void { } } -/** - * Synchronize the select all confirmation checkbox state with the select all confirmation button - * disabled state. If the select all confirmation checkbox is checked, the buttons should be - * enabled. If not, the buttons should be disabled. - * - * @param event Change Event - */ -function handleSelectAll(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const selectAllBox = getElement('select-all-box'); - if (selectAllBox !== null) { - for (const button of selectAllBox.querySelectorAll( - 'button[type="submit"]', - )) { - if (target.checked) { - button.disabled = false; - } else { - button.disabled = true; - } - } - } -} - /** * Initialize table select all elements. */ @@ -98,9 +75,4 @@ export function initSelectAll(): void { for (const element of getElements('input[type="checkbox"][name="pk"]')) { element.addEventListener('change', handlePkCheck); } - const selectAll = getElement('select-all'); - - if (selectAll !== null) { - selectAll.addEventListener('change', handleSelectAll); - } } diff --git a/netbox/project-static/src/clipboard.ts b/netbox/project-static/src/clipboard.ts index a04acba39..46ca5e36c 100644 --- a/netbox/project-static/src/clipboard.ts +++ b/netbox/project-static/src/clipboard.ts @@ -2,7 +2,7 @@ import Clipboard from 'clipboard'; import { getElements } from './util'; export function initClipboard(): void { - for (const element of getElements('a.copy-token', 'button.copy-secret')) { + for (const element of getElements('a.copy-content')) { new Clipboard(element); } } diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index 09d423cbd..2410a5fd9 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util'; import { initButtons } from './buttons'; import { initSelect } from './select'; import { initObjectSelector } from './objectSelector'; +import { initBootstrap } from './bs'; function initDepedencies(): void { - for (const init of [initButtons, initSelect, initObjectSelector]) { + for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) { init(); } } @@ -22,4 +23,8 @@ export function initHtmx(): void { } } } + + for (const element of getElements('[hx-trigger=load]')) { + element.addEventListener('htmx:afterSettle', initDepedencies); + } } diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index f5b605d58..53996910e 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -1,5 +1,6 @@ import { readableColor } from 'color2k'; import debounce from 'just-debounce-it'; +import { encode } from 'html-entities'; import queryString from 'query-string'; import SlimSelect from 'slim-select'; import { createToast } from '../../bs'; @@ -446,7 +447,7 @@ export class APISelect { // Build SlimSelect options from all already-selected options. const preSelectedOptions = preSelected.map(option => ({ value: option.value, - text: option.innerText, + text: encode(option.innerText), selected: true, disabled: false, })) as Option[]; @@ -454,7 +455,7 @@ export class APISelect { let options = [] as Option[]; for (const result of data.results) { - let text = result.display; + let text = encode(result.display); if (typeof result._depth === 'number' && result._depth > 0) { // If the object has a `_depth` property, indent its display text. diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index b294d67bd..94fddc32c 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -1002,6 +1002,18 @@ div.card-overlay { padding: 8px; } +th[align="left"] { + text-align: left; +} + +th[align="center"] { + text-align: center; +} + +th[align="right"] { + text-align: right; +} + /* Markdown widget */ .markdown-widget { .nav-link { diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index c4bee7557..2adc50001 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -1818,6 +1818,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +html-entities@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" + integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== + htmx.org@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3" diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 6cface941..0257e7c43 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -30,7 +30,10 @@ {{ error }} Python version: {{ python_version }} -NetBox version: {{ netbox_version }} +NetBox version: {{ netbox_version }} +Plugins: {% for plugin, version in plugins.items %} + {{ plugin }}: {{ version }}{% empty %}None installed{% endfor %} +

If further assistance is required, please post to the NetBox discussion forum on GitHub.

diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 6b247d81a..9e8b7d7bf 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -77,10 +77,10 @@ Blocks: {% endif %} - {% if config.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} {% endif %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ee994e959..a5913e2ad 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -70,7 +70,6 @@
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 5a565ea29..c721d5a58 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -43,7 +43,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html index 63344ada1..c55663b4a 100644 --- a/netbox/templates/circuits/provideraccount.html +++ b/netbox/templates/circuits/provideraccount.html @@ -38,7 +38,6 @@ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/comments.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html index 3d79d17e2..785617ae5 100644 --- a/netbox/templates/core/datafile.html +++ b/netbox/templates/core/datafile.html @@ -39,9 +39,7 @@ Path {{ object.path }} - - - + {% copy_content "datafile_path" %} @@ -56,9 +54,7 @@ SHA256 Hash {{ object.hash }} - - - + {% copy_content "datafile_hash" %} diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index c69569358..992edb2d1 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -88,7 +88,11 @@ {% for name, field in object.get_backend.parameters.items %} {{ field.label }} - {{ object.parameters|get_key:name|placeholder }} + {% if name in object.get_backend.sensitive_parameters and not perms.core.change_datasource %} + ******** + {% else %} + {{ object.parameters|get_key:name|placeholder }} + {% endif %} {% empty %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index aa1b80cf7..df5209add 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -194,12 +194,13 @@ Primary IPv4 {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} + {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip4" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -209,12 +210,13 @@ Primary IPv6 {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} + {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip6" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -298,7 +300,6 @@
{% endif %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %}
Dimensions
diff --git a/netbox/templates/dcim/device/components_base.html b/netbox/templates/dcim/device/components_base.html new file mode 100644 index 000000000..1e3d8a39d --- /dev/null +++ b/netbox/templates/dcim/device/components_base.html @@ -0,0 +1,15 @@ +{% extends 'generic/object_children.html' %} +{% load helpers %} + +{% block bulk_edit_controls %} + {{ block.super }} + {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} + {% if 'bulk_rename' in actions and bulk_rename_view %} + + {% endif %} + {% endwith %} +{% endblock bulk_edit_controls %} diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index ccd12f61c..6e1c1b699 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_consoleport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_consoleport %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 43396651d..637f06118 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_consoleserverport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_consoleserverport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 9453b9a59..0a7bbba7f 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -1,50 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_devicebay %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_devicebay %} - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index dd0767d95..453064611 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_frontport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_frontport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index c0e9a38b6..778101265 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -1,66 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} -
- {% if 'bulk_delete' in actions %} - +{% block bulk_delete_controls %} + {{ block.super }} + {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} + {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} + {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
+ {% endwith %} +{% endblock bulk_delete_controls %} + +{% block bulk_extra_controls %} + {{ block.super }} {% if perms.dcim.add_interface %} - + {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 9e11031ec..d4c9a9b68 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -1,50 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_inventoryitem %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_inventoryitem %} - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index 7f0aacf1f..fc616f828 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -1,46 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_modulebay %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_modulebay %} - {% endif %} -
-
- {% table_config_form table %} -{% endblock %} + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 66b21b7af..f31067453 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_poweroutlet %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_poweroutlet %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index d9e1e121a..ad1dbacd8 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_powerport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_powerport %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index ce194cc78..dfa406386 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_rearport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_rearport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/render_config.html b/netbox/templates/dcim/device/render_config.html index b6e16701f..dfda7cdf6 100644 --- a/netbox/templates/dcim/device/render_config.html +++ b/netbox/templates/dcim/device/render_config.html @@ -28,8 +28,22 @@
-
Context Data
-
{{ context_data|pprint }}
+
+
+
+

+ +

+
+
+
{{ context_data|pprint }}
+
+
+
+
+
diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0ee4c1ccf..bb28be9a2 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -15,15 +15,14 @@ Rack {{ terminations.0.device.rack|linkify|placeholder }} - - Device - {{ terminations.0.device|linkify }} - {{ terminations.0|meta:"verbose_name"|capfirst }} {% for term in terminations %} - {{ term|linkify }}{% if not forloop.last %},{% endif %} + {{term.device|linkify}} + + {{ term|linkify }} + {% if not forloop.last %}
{% endif %} {% endfor %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index db0fd7dfd..11f262eeb 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -123,11 +123,11 @@ - + - + diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 743622ee6..dae6d78ff 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -65,7 +65,6 @@
{% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index a60b3503c..8233b6fc8 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -51,7 +51,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index af08f3023..ea9210ba7 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -40,7 +40,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 2aed3da33..2b7f90509 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -190,7 +190,6 @@ {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rack/non_racked_devices.html b/netbox/templates/dcim/rack/non_racked_devices.html new file mode 100644 index 000000000..e52b8647f --- /dev/null +++ b/netbox/templates/dcim/rack/non_racked_devices.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_children.html' %} + +{% block extra_controls %} + {% if perms.dcim.add_device %} + + {% endif %} +{% endblock extra_controls %} diff --git a/netbox/templates/dcim/rack/reservations.html b/netbox/templates/dcim/rack/reservations.html index fb357e592..a01cf3b7e 100644 --- a/netbox/templates/dcim/rack/reservations.html +++ b/netbox/templates/dcim/rack/reservations.html @@ -1,43 +1,12 @@ -{% extends 'dcim/rack/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="RackReservationTable_config" %} - - - {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_rackreservation %} +{% block extra_controls %} + {% if perms.dcim.add_rackreservation %} - {% endif %} -
- -{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock extra_controls %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 85587e4b5..05cc424d7 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -46,7 +46,6 @@
{% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0cb5ce826..1d57de19b 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -131,7 +131,49 @@
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/contacts.html' %} +
+
Locations
+
+ {% if locations %} +
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
WWN{{ object.wwn|placeholder }}{{ object.wwn|placeholder }}
VRF
+ + + + + + + {% for location in locations %} + + + + + + + {% endfor %} +
LocationRacksDevices
+ {% for i in location.level|as_range %}{% endfor %} + {{ location|linkify }} + + {{ location.rack_count }} + + {{ location.device_count }} + + + + +
+ {% else %} + None + {% endif %} + + {% if perms.dcim.add_location %} + + {% endif %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 2cf8e7168..819022a34 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -42,7 +42,6 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html index d6e3e0c63..1caf05bd2 100644 --- a/netbox/templates/dcim/virtualdevicecontext.html +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -31,13 +31,23 @@ Primary IPv4 - {{ object.primary_ip4|linkify|placeholder }} + {% if object.primary_ip4 %} + {{ object.primary_ip4 }} + {% copy_content "primary_ip4" %} + {% else %} + + {% endif %} Primary IPv6 - {{ object.primary_ip6|linkify|placeholder }} + {% if object.primary_ip6 %} + {{ object.primary_ip6 }} + {% copy_content "primary_ip6" %} + {% else %} + + {% endif %} diff --git a/netbox/templates/extras/dashboard/widgets/objectcounts.html b/netbox/templates/extras/dashboard/widgets/objectcounts.html index d0e604c9a..8b68dc166 100644 --- a/netbox/templates/extras/dashboard/widgets/objectcounts.html +++ b/netbox/templates/extras/dashboard/widgets/objectcounts.html @@ -1,10 +1,8 @@ -{% load helpers %} - {% if counts %}
- {% for model, count in counts %} + {% for model, count, url in counts %} {% if count != None %} - +
{{ model|meta:"verbose_name_plural"|bettertitle }}
{{ count }}
diff --git a/netbox/templates/extras/imageattachment.html b/netbox/templates/extras/imageattachment.html new file mode 100644 index 000000000..1968344cc --- /dev/null +++ b/netbox/templates/extras/imageattachment.html @@ -0,0 +1,4 @@ +{% extends 'generic/object.html' %} + +{% block tabs %} +{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 0c27eefda..e1efec755 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -38,71 +38,77 @@
{% include 'inc/sync_warning.html' with object=module %} - - - - - - - - - - - - {% with jobs=module.get_latest_jobs %} - {% for report_name, report in module.reports.items %} - {% with last_job=jobs|get_key:report.name %} - - - - {% if last_job %} - - - {% else %} - - - {% endif %} - - - {% for method, stats in last_job.data.items %} + {% if module.reports %} +
NameDescriptionLast RunStatus
- {{ report.name }} - {{ report.description|markdown|placeholder }} - {{ last_job.created|annotated_date }} - - {% badge last_job.get_status_display last_job.get_status_color %} - Never{{ ''|placeholder }} - {% if perms.extras.run_report %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
+ + + + + + + + + + + {% with jobs=module.get_latest_jobs %} + {% for report_name, report in module.reports.items %} + {% with last_job=jobs|get_key:report.class_name %} - - + {% if last_job %} + + + {% else %} + + + {% endif %} + - {% endfor %} - {% endwith %} - {% endfor %} - {% endwith %} - -
NameDescriptionLast RunStatus
- {{ method }} + + {{ report.name }} - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} + {{ report.description|markdown|placeholder }} + {{ last_job.created|annotated_date }} + + {% badge last_job.get_status_display last_job.get_status_color %} + Never{{ ''|placeholder }} + {% if perms.extras.run_report %} +
+
+ {% csrf_token %} + +
+
+ {% endif %}
+ {% for method, stats in last_job.data.items %} + + + {{ method }} + + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} + + + {% endfor %} + {% endwith %} + {% endfor %} + {% endwith %} + + + {% else %} + + {% endif %}
{% empty %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index 9358af364..f6a9a6398 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -4,7 +4,7 @@ {% block content-wrapper %}
-
+
{% include 'extras/htmx/report_result.html' %}
diff --git a/netbox/templates/extras/schema/devicetype_schema.jinja2 b/netbox/templates/extras/schema/devicetype_schema.jinja2 new file mode 100644 index 000000000..b08ab24de --- /dev/null +++ b/netbox/templates/extras/schema/devicetype_schema.jinja2 @@ -0,0 +1,93 @@ +{ + "type": "object", + "additionalProperties": false, + "definitions": { + "airflow": { + "type": "string", + "enum": {{ airflow_choices }} + }, + "weight-unit": { + "type": "string", + "enum": {{ weight_unit_choices }} + }, + "subdevice-role": { + "type": "string", + "enum": {{ subdevice_role_choices }} + }, + "console-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ console_port_type_choices }} + } + } + }, + "console-server-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ console_server_port_type_choices }} + } + } + }, + "power-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ power_port_type_choices }} + } + } + }, + "power-outlet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ power_outlet_type_choices }} + }, + "feed-leg": { + "type": "string", + "enum": {{ power_outlet_feedleg_choices }} + } + } + }, + "interface": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ interface_type_choices }} + }, + "poe_mode": { + "type": "string", + "enum": {{ interface_poe_mode_choices }} + }, + "poe_type": { + "type": "string", + "enum": {{ interface_poe_type_choices }} + } + } + }, + "front-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ front_port_type_choices }} + } + } + }, + "rear-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ rear_port_type_choices}} + } + } + } + } +} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index b7ef2a908..b515e8a99 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,9 +15,9 @@
{% csrf_token %}
- {% if form.requires_input %} - {# Render grouped fields according to declared fieldsets #} - {% for group, fields in script.get_fieldsets %} + {# Render grouped fields according to declared fieldsets #} + {% for group, fields in script.get_fieldsets %} + {% if fields %}
{{ group }}
@@ -28,14 +28,8 @@ {% endwith %} {% endfor %}
- {% endfor %} - {% else %} -
- - This script does not require any input to run. -
- {% render_form form %} - {% endif %} + {% endif %} + {% endfor %}
Cancel diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 9a67e2b10..0f32ba0b9 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -61,7 +61,7 @@ {{ script_class.Meta.description|markdown|placeholder }} - {% with last_result=jobs|get_key:script_class.name %} + {% with last_result=jobs|get_key:script_class.class_name %} {% if last_result %} {{ last_result.created|annotated_date }} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 4dfd7482a..436ba7354 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -47,7 +47,7 @@
-
+
{% include 'extras/htmx/script_result.html' %}
diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html new file mode 100644 index 000000000..eb5c65827 --- /dev/null +++ b/netbox/templates/generic/object_children.html @@ -0,0 +1,57 @@ +{% extends base_template %} +{% load helpers %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal=table_config %} + + {% csrf_token %} +
+
+ {% include 'htmx/table.html' %} +
+
+
+ {% block bulk_controls %} +
+
+ {# Bulk edit buttons #} + {% block bulk_edit_controls %} + {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} + {% if 'bulk_edit' in actions and bulk_edit_view %} + + {% endif %} + {% endwith %} + {% endblock bulk_edit_controls %} +
+
+ {# Bulk delete buttons #} + {% block bulk_delete_controls %} + {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %} + {% if 'bulk_delete' in actions and bulk_delete_view %} + + {% endif %} + {% endwith %} + {% endblock bulk_delete_controls %} +
+
+
+ {# Other bulk action buttons #} + {% block bulk_extra_controls %}{% endblock %} +
+ {% endblock bulk_controls %} +
+ +{% endblock content %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html deleted file mode 100644 index 359ad8d7e..000000000 --- a/netbox/templates/inc/panels/contacts.html +++ /dev/null @@ -1,63 +0,0 @@ -{% load helpers %} - -
-
Contacts
-
- {% with contacts=object.contacts.all %} - {% if contacts.exists %} - - - - - - - - - - {% for contact in contacts %} - - - - - - - - - {% endfor %} -
NameRolePriorityPhoneEmail
{{ contact.contact|linkify }}{{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} - {% if contact.contact.phone %} - {{ contact.contact.phone }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if contact.contact.email %} - {{ contact.contact.email }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if perms.tenancy.change_contactassignment %} - - - - {% endif %} - {% if perms.tenancy.delete_contactassignment %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} -
- {% if perms.tenancy.add_contactassignment %} - - {% endif %} -
diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index 0c1d212d9..a09fe78d5 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -1,12 +1,8 @@ {% load helpers %}
-
- Images -
-
+
Images
+ {% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %} {% if perms.extras.add_imageattachment %}
- {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index fe68039f8..f9d5febbe 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% if perms.ipam.add_ipaddress and first_available_ip %} @@ -7,38 +6,4 @@ Add IP Address {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 4452fd5a7..8371de81d 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% if perms.ipam.add_iprange and first_available_ip %} @@ -7,38 +6,4 @@ Add IP Range {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 5fc931f74..41407e870 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} @@ -8,39 +7,4 @@ Add Prefix {% endif %} - {{ block.super }} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html deleted file mode 100644 index f7bcc8563..000000000 --- a/netbox/templates/ipam/vlan/interfaces.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'ipam/vlan/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html deleted file mode 100644 index a485b33eb..000000000 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'ipam/vlan/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 2917536be..e474cbd84 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -42,6 +42,10 @@ Permitted VIDs {{ object.min_vid }} - {{ object.max_vid }} + + Utilization + {% utilization_graph object.utilization %} +
diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html new file mode 100644 index 000000000..95727604c --- /dev/null +++ b/netbox/templates/tenancy/object_contacts.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_children.html' %} +{% load helpers %} + +{% block extra_controls %} + {% if perms.tenancy.add_contactassignment %} + {% with viewname=object|viewname:"contacts" %} + + Add a contact + + {% endwith %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index da48f1ef5..34abe5c01 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -30,7 +30,6 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/api_token.html index 1a9296704..7fd6f064d 100644 --- a/netbox/templates/users/api_token.html +++ b/netbox/templates/users/api_token.html @@ -8,7 +8,7 @@
{% if not settings.ALLOW_TOKEN_RETRIEVAL %} {% endif %}
@@ -19,9 +19,7 @@ Key
- - - + {% copy_content "token_id" %}
{{ key }}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 3dfef108b..508bca547 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -84,7 +84,6 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index 083798233..271240ed1 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -1,30 +1,13 @@ -{% extends 'virtualization/cluster/base.html' %} +{% extends 'generic/object_children.html' %} {% load helpers %} -{% load render_table from django_tables2 %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-
- {% if perms.virtualization.change_cluster %} - - {% endif %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + + {% endif %} +{% endblock bulk_delete_controls %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html deleted file mode 100644 index 79c489d6b..000000000 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'virtualization/cluster/base.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index 510433068..2496ad085 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -37,7 +37,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5098a2f8f..3d3b498ad 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -46,12 +46,13 @@ Primary IPv4 {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} + {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip4" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -61,12 +62,13 @@ Primary IPv6 {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} + {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip6" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -158,7 +160,6 @@
{% endif %}
- {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 71456d104..ee4e76926 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -1,47 +1,13 @@ -{% extends 'virtualization/virtualmachine/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'generic/object_children.html' %} {% load helpers %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
- {% if perms.virtualization.change_vminterface %} -
- - -
- {% endif %} - {% if perms.virtualization.delete_vminterface %} - - {% endif %} - {% if perms.virtualization.add_vminterface %} - - {% endif %} -
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_edit_controls %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index a7d4d92ba..82071bdaa 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -59,7 +59,7 @@ MAC Address - {{ object.mac_address|placeholder }} + {{ object.mac_address|placeholder }} 802.1Q Mode diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f9b8accd9..0aec0e28f 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,9 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import * -from utilities.forms.fields import CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField __all__ = ( + 'ContactAssignmentImportForm', 'ContactImportForm', 'ContactGroupImportForm', 'ContactRoleImportForm', @@ -81,3 +83,27 @@ class ContactImportForm(NetBoxModelImportForm): class Meta: model = Contact fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') + + +class ContactAssignmentImportForm(NetBoxModelImportForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + help_text=_("One or more assigned object types") + ) + contact = CSVModelChoiceField( + queryset=Contact.objects.all(), + to_field_name='name', + help_text=_('Assigned contact') + ) + role = CSVModelChoiceField( + queryset=ContactRole.objects.all(), + to_field_name='name', + help_text=_('Assigned role') + ) + + # Remove the tags field added by NetBoxModelImportForm (unsupported by ContactAssignment) + tags = None + + class Meta: + model = ContactAssignment + fields = ('content_type', 'object_id', 'contact', 'priority', 'role') diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 440541b5f..1df5e3305 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel): def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.object + return objectchange diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 0c697af79..7de8ffceb 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -1,4 +1,5 @@ import django_tables2 as tables +from django_tables2.utils import Accessor from netbox.tables import NetBoxTable, columns from tenancy.models import * @@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable): role = tables.Column( linkify=True ) + contact_title = tables.Column( + accessor=Accessor('contact__title'), + verbose_name='Contact Title' + ) + contact_phone = tables.Column( + accessor=Accessor('contact__phone'), + verbose_name='Contact Phone' + ) + contact_email = tables.Column( + accessor=Accessor('contact__email'), + verbose_name='Contact Email' + ) + contact_address = tables.Column( + accessor=Accessor('contact__address'), + verbose_name='Contact Address' + ) + contact_link = tables.Column( + accessor=Accessor('contact__link'), + verbose_name='Contact Link' + ) + contact_description = tables.Column( + accessor=Accessor('contact__description'), + verbose_name='Contact Description' + ) actions = columns.ActionsColumn( actions=('edit', 'delete') ) class Meta(NetBoxTable.Meta): model = ContactAssignment - fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') - default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') + fields = ( + 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', + 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions' + ) + default_columns = ( + 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' + ) diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 87491ea0e..ad9908c62 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -49,6 +49,7 @@ urlpatterns = [ # Contact assignments path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'), path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), + path('contact-assignments/import/', views.ContactAssignmentBulkImportView.as_view(), name='contactassignment_import'), path('contact-assignments/edit/', views.ContactAssignmentBulkEditView.as_view(), name='contactassignment_bulk_edit'), path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'), path('contact-assignments//', include(get_model_urls('tenancy', 'contactassignment'))), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b9ada8640..3025e7e04 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,17 +7,45 @@ from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, Vi from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic from utilities.utils import count_related -from utilities.views import register_model_view +from utilities.views import register_model_view, ViewTab from virtualization.models import VirtualMachine, Cluster from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * +class ObjectContactsView(generic.ObjectChildrenView): + child_model = ContactAssignment + table = tables.ContactAssignmentTable + filterset = filtersets.ContactAssignmentFilterSet + template_name = 'tenancy/object_contacts.html' + tab = ViewTab( + label=_('Contacts'), + badge=lambda obj: obj.contacts.count(), + permission='tenancy.view_contactassignment', + weight=5000 + ) + + def get_children(self, request, parent): + return ContactAssignment.objects.restrict(request.user, 'view').filter( + content_type=ContentType.objects.get_for_model(parent), + object_id=parent.pk + ) + + def get_table(self, *args, **kwargs): + table = super().get_table(*args, **kwargs) + + # Hide object columns + table.columns.hide('content_type') + table.columns.hide('object') + + return table + # # Tenant groups # + class TenantGroupListView(generic.ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), @@ -165,6 +193,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView): table = tables.TenantTable +@register_model_view(Tenant, 'contacts') +class TenantContactsView(ObjectContactsView): + queryset = Tenant.objects.all() + + # # Contact groups # @@ -342,11 +375,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView): filterset = filtersets.ContactFilterSet table = tables.ContactTable - # # Contact assignments # + class ContactAssignmentListView(generic.ObjectListView): queryset = ContactAssignment.objects.all() filterset = filtersets.ContactAssignmentFilterSet @@ -382,6 +415,11 @@ class ContactAssignmentBulkEditView(generic.BulkEditView): form = forms.ContactAssignmentBulkEditForm +class ContactAssignmentBulkImportView(generic.BulkImportView): + queryset = ContactAssignment.objects.all() + model_form = forms.ContactAssignmentImportForm + + class ContactAssignmentBulkDeleteView(generic.BulkDeleteView): queryset = ContactAssignment.objects.all() filterset = filtersets.ContactAssignmentFilterSet diff --git a/netbox/users/signals.py b/netbox/users/signals.py index 8915af1dc..98036d5d1 100644 --- a/netbox/users/signals.py +++ b/netbox/users/signals.py @@ -1,10 +1,18 @@ import logging from django.dispatch import receiver from django.contrib.auth.signals import user_login_failed +from utilities.request import get_client_ip @receiver(user_login_failed) def log_user_login_failed(sender, credentials, request, **kwargs): logger = logging.getLogger('netbox.auth.login') username = credentials.get("username") - logger.info(f"Failed login attempt for username: {username}") + if client_ip := get_client_ip(request): + logger.info(f"Failed login attempt for username: {username} from {client_ip}") + else: + logger.warning( + "Client IP address could not be determined for validation. Check that the HTTP server is properly " + "configured to pass the required header(s)." + ) + logger.info(f"Failed login attempt for username: {username}") diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 0f1484887..cea50b10f 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -12,9 +12,7 @@ ALLOWED_IPS = """{{ value|join:", " }}""" COPY_BUTTON = """ {% if settings.ALLOW_TOKEN_RETRIEVAL %} - - - + {% copy_content record.pk prefix="token_" color="success" %} {% endif %} """ diff --git a/netbox/users/views.py b/netbox/users/views.py index a82620914..05648e2e3 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -159,7 +159,9 @@ class ProfileView(LoginRequiredMixin, View): def get(self, request): # Compile changelog table - changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related( + changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( + user=request.user + ).prefetch_related( 'changed_object_type' )[:20] changelog_table = ObjectChangeTable(changelog) diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index b7f432e63..6bdfd5662 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -123,9 +123,9 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): records = [] try: for data in yaml.load_all(data, Loader=yaml.SafeLoader): - if type(data) == list: + if type(data) is list: records.extend(data) - elif type(data) == dict: + elif type(data) is dict: records.append(data) else: raise forms.ValidationError({ diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 5100b1714..4d737f163 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -60,6 +60,9 @@ def parse_alphanumeric_range(string): except ValueError: begin, end = dash_range, dash_range if begin.isdigit() and end.isdigit(): + if int(begin) >= int(end): + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + for n in list(range(int(begin), int(end) + 1)): values.append(n) else: @@ -71,6 +74,10 @@ def parse_alphanumeric_range(string): # Not a valid range (more than a single character) if not len(begin) == len(end) == 1: raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + + if ord(begin) >= ord(end): + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + for n in list(range(ord(begin), ord(end) + 1)): values.append(chr(n)) return values diff --git a/netbox/utilities/rqworker.py b/netbox/utilities/rqworker.py index 5866dfee0..61f594767 100644 --- a/netbox/utilities/rqworker.py +++ b/netbox/utilities/rqworker.py @@ -1,11 +1,12 @@ from django_rq.queues import get_connection -from rq import Worker +from rq import Retry, Worker from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT __all__ = ( 'get_queue_for_model', + 'get_rq_retry', 'get_workers_for_queue', ) @@ -22,3 +23,14 @@ def get_workers_for_queue(queue_name): Returns True if a worker process is currently servicing the specified queue. """ return Worker.count(get_connection(queue_name)) + + +def get_rq_retry(): + """ + If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be + used when queuing a job. Otherwise, return None. + """ + retry_max = get_config().RQ_RETRY_MAX + retry_interval = get_config().RQ_RETRY_INTERVAL + if retry_max: + return Retry(max=retry_max, interval=retry_interval) diff --git a/netbox/utilities/templates/builtins/copy_content.html b/netbox/utilities/templates/builtins/copy_content.html new file mode 100644 index 000000000..9025a71a1 --- /dev/null +++ b/netbox/utilities/templates/builtins/copy_content.html @@ -0,0 +1,3 @@ + + + diff --git a/netbox/utilities/templates/builtins/htmx_table.html b/netbox/utilities/templates/builtins/htmx_table.html new file mode 100644 index 000000000..7e871931c --- /dev/null +++ b/netbox/utilities/templates/builtins/htmx_table.html @@ -0,0 +1,4 @@ +
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index cdc517b97..35aec1000 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,8 +1,12 @@ from django import template +from django.http import QueryDict + +from utilities.utils import dict_to_querydict __all__ = ( 'badge', 'checkmark', + 'copy_content', 'customfield_value', 'tag', ) @@ -74,3 +78,32 @@ def checkmark(value, show_false=True, true='Yes', false='No'): 'true_label': true, 'false_label': false, } + + +@register.inclusion_tag('builtins/copy_content.html') +def copy_content(target, prefix=None, color='primary'): + """ + Display a copy button to copy the content of a field. + """ + return { + 'target': f'#{prefix or ""}{target}', + 'color': f'btn-{color}' + } + + +@register.inclusion_tag('builtins/htmx_table.html', takes_context=True) +def htmx_table(context, viewname, return_url=None, **kwargs): + """ + Embed an object list table retrieved using HTMX. Any extra keyword arguments are passed as URL query parameters. + + Args: + context: The current request context + viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`) + return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used. + """ + url_params = dict_to_querydict(kwargs) + url_params['return_url'] = return_url or context['request'].path + return { + 'viewname': viewname, + 'url_params': url_params, + } diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 09a083112..aaee9679c 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -114,7 +114,7 @@ def annotated_date(date_value): if not date_value: return '' - if type(date_value) == datetime.date: + if type(date_value) is datetime.date: long_ts = date(date_value, 'DATE_FORMAT') short_ts = date(date_value, 'SHORT_DATE_FORMAT') else: diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index b8cff2996..79ba3f4d8 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -264,8 +264,9 @@ class ExpandAlphanumeric(TestCase): self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), []) def test_invalid_range_bounds(self): - self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-8]a')), []) - self.assertEqual(sorted(expand_alphanumeric_pattern('r[b-a]a')), []) + with self.assertRaises(forms.ValidationError): + sorted(expand_alphanumeric_pattern('r[9-8]a')) + sorted(expand_alphanumeric_pattern('r[b-a]a')) def test_invalid_range_len(self): with self.assertRaises(forms.ValidationError): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index b1504e62f..9524e242c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -11,8 +11,9 @@ from django.core import serializers from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict -from django.utils.html import escape from django.utils import timezone +from django.utils.datastructures import MultiValueDict +from django.utils.html import escape from django.utils.timezone import localtime from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel @@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''): return params +def dict_to_querydict(d, mutable=True): + """ + Create a QueryDict instance from a regular Python dictionary. + """ + qd = QueryDict(mutable=True) + for k, v in d.items(): + item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v} + qd.update(item) + if not mutable: + qd._mutable = False + return qd + + def normalize_querydict(querydict): """ Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example, @@ -302,7 +316,7 @@ def to_meters(length, unit): if unit == CableLengthUnitChoices.UNIT_FOOT: return length * Decimal(0.3048) if unit == CableLengthUnitChoices.UNIT_INCH: - return length * Decimal(0.3048) * 12 + return length * Decimal(0.0254) raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") @@ -505,6 +519,8 @@ def clean_html(html, schemes): "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"], "a": ["href", "title"], "img": ["src", "title", "alt"], + "th": ["align"], + "td": ["align"], } return bleach.clean( diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 43ca9a589..589b71f50 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -178,7 +178,7 @@ def register_model_view(model, name='', path=None, kwargs=None): This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: - @netbox_model_view(Site, 'myview', path='my-custom-view') + @register_model_view(Site, 'myview', path='my-custom-view') class MyView(ObjectView): ... diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index a229bd935..15651f2ae 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -65,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') + fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags') class VirtualMachineImportForm(NetBoxModelImportForm): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 9014aa9dd..92a91f47e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib import messages from django.db import transaction from django.db.models import Prefetch, Sum @@ -9,9 +11,10 @@ from dcim.filtersets import DeviceFilterSet from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView -from ipam.models import IPAddress, Service +from ipam.models import IPAddress from ipam.tables import InterfaceVLANTable from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from . import filtersets, forms, tables @@ -140,6 +143,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView): table = tables.ClusterGroupTable +@register_model_view(ClusterGroup, 'contacts') +class ClusterGroupContactsView(ObjectContactsView): + queryset = ClusterGroup.objects.all() + + # # Clusters # @@ -169,7 +177,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView): child_model = VirtualMachine table = tables.VirtualMachineTable filterset = filtersets.VirtualMachineFilterSet - template_name = 'virtualization/cluster/virtual_machines.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Virtual Machines'), badge=lambda obj: obj.virtual_machines.count(), @@ -188,6 +196,13 @@ class ClusterDevicesView(generic.ObjectChildrenView): table = DeviceTable filterset = DeviceFilterSet template_name = 'virtualization/cluster/devices.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_remove_devices': {'change'}, + }) tab = ViewTab( label=_('Devices'), badge=lambda obj: obj.devices.count(), @@ -312,6 +327,11 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): }) +@register_model_view(Cluster, 'contacts') +class ClusterContactsView(ObjectContactsView): + queryset = Cluster.objects.all() + + # # Virtual machines # @@ -342,6 +362,14 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): permission='virtualization.view_vminterface', weight=500 ) + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + 'bulk_rename': {'change'}, + }) def get_children(self, request, parent): return parent.interfaces.restrict(request.user, 'view').prefetch_related( @@ -390,6 +418,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView): table = tables.VirtualMachineTable +@register_model_view(VirtualMachine, 'contacts') +class VirtualMachineContactsView(ObjectContactsView): + queryset = VirtualMachine.objects.all() + + # # VM interfaces # @@ -399,7 +432,6 @@ class VMInterfaceListView(generic.ObjectListView): filterset = filtersets.VMInterfaceFilterSet filterset_form = forms.VMInterfaceFilterForm table = tables.VMInterfaceTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(VMInterface) diff --git a/requirements.txt b/requirements.txt index c3d9c8c38..2ea0f2522 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,37 @@ bleach==6.0.0 -boto3==1.26.127 -Django==4.1.9 -django-cors-headers==3.14.0 -django-debug-toolbar==4.0.0 +boto3==1.28.26 +Django==4.1.10 +django-cors-headers==4.2.0 +django-debug-toolbar==4.2.0 django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.3.1 -django-redis==5.2.0 -django-rich==1.5.0 -django-rq==2.8.0 -django-tables2==2.5.3 +django-redis==5.3.0 +django-rich==1.7.0 +django-rq==2.8.1 +django-tables2==2.6.0 django-taggit==4.0.0 -django-timezone-field==5.0 +django-timezone-field==5.1 djangorestframework==3.14.0 -drf-spectacular==0.26.2 -drf-spectacular-sidecar==2023.5.1 +drf-spectacular==0.26.4 +drf-spectacular-sidecar==2023.8.1 dulwich==0.21.5 feedparser==6.0.10 graphene-django==3.0.0 -gunicorn==20.1.0 +gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.9 -mkdocstrings[python-legacy]==0.21.2 +mkdocs-material==9.1.21 +mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 -Pillow==9.5.0 -psycopg2-binary==2.9.6 -PyYAML==6.0 -sentry-sdk==1.22.1 +Pillow==10.0.0 +psycopg2-binary==2.9.7 +PyYAML==6.0.1 +sentry-sdk==1.29.2 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3 -tablib==3.4.0 +tablib==3.5.0 tzdata==2023.3