diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a83e9b34e..a37c5dfb1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -56,8 +56,3 @@ body: placeholder: "A TypeError exception was raised" validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml index 3b2026b34..b480e629a 100644 --- a/.github/ISSUE_TEMPLATE/documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -14,25 +14,22 @@ body: - Cleanup (formatting, typos, etc.) validations: required: true - - type: checkboxes + - type: dropdown attributes: label: Area - description: To what section(s) of the documentation does this change pertain? + description: To what section of the documentation does this change primarily pertain? options: - - label: Installation instructions - - label: Configuration parameters - - label: Functionality/features - - label: REST API - - label: Administration/development - - label: Other + - Installation instructions + - Configuration parameters + - Functionality/features + - REST API + - Administration/development + - Other + validations: + required: true - type: textarea attributes: label: Proposed Changes description: "Describe the proposed changes and why they are necessary" validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index efa83b376..6282eedde 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -51,8 +51,3 @@ body: description: "List any new dependencies on external libraries or services that this new feature would introduce. For example, does the proposal require the installation of a new Python package? (Not all new features introduce new dependencies.)" - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yaml b/.github/ISSUE_TEMPLATE/housekeeping.yaml index 0f466aa24..778dca235 100644 --- a/.github/ISSUE_TEMPLATE/housekeeping.yaml +++ b/.github/ISSUE_TEMPLATE/housekeeping.yaml @@ -20,8 +20,3 @@ body: description: "Please provide justification for the proposed change(s)." validations: required: true - - type: markdown - attributes: - value: | - ### Additional information - You can use the space below to provide any additional information or to attach files. diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 92da07e6a..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Configuration for Stale (https://github.com/apps/stale) - -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 45 - -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 15 - -# Issues with these labels will never be considered stale -exemptLabels: - - "status: accepted" - - "status: blocked" - - "status: needs milestone" - -# Label to use when marking an issue as stale -staleLabel: "pending closure" - -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. NetBox - is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). - -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: > - This issue has been automatically closed due to lack of activity. In an - effort to reduce noise, please do not comment any further. Note that the - core maintainers may elect to reopen this issue at a later date if deemed - necessary. diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..8fc85ead6 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,34 @@ +# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) +name: 'Close stale issues/PRs' +on: + schedule: + - cron: '0 4 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + with: + close-issue-message: > + This issue has been automatically closed due to lack of activity. In an + effort to reduce noise, please do not comment any further. Note that the + core maintainers may elect to reopen this issue at a later date if deemed + necessary. + close-pr-message: > + This PR has been automatically closed due to lack of activity. + days-before-stale: 45 + days-before-close: 15 + exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone' + remove-stale-when-updated: false + stale-issue-label: 'pending closure' + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. NetBox + is governed by a small group of core maintainers which means not all opened + issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + stale-pr-label: 'pending closure' + stale-pr-message: > + This PR has been automatically marked as stale because it has not had + recent activity. It will be closed automatically if no further action is + taken. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd62c5ab7..009e6586c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ discussions. ### Slack -For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ). +For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://slack.netbox.dev/). Unfortunately, the Slack channel does not provide long-term retention of chat history, so try to avoid it for any discussions would benefit from being preserved for future reference. diff --git a/README.md b/README.md index 880fa8c08..e35b72c2e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne ### Discussion * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions -* [Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out +* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions ### Build Status diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md index 359d65202..ebce63578 100644 --- a/docs/additional-features/caching.md +++ b/docs/additional-features/caching.md @@ -1,6 +1,6 @@ # Caching -NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. +NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database. diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 5c74c6744..618d07052 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/additional-features/custom-fields.md @@ -38,9 +38,13 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. -If a default value is specified for a selection field, it must exactly match one of the provided choices. +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. -The value of a multiple selection field will always return a list, even if only one value is selected. +## Custom Fields in Templates + +Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`). + +For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`. ## Custom Fields and the REST API diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index 1e0611f06..b3f585bee 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/additional-features/export-templates.md @@ -2,7 +2,10 @@ NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. + +!!! note + The name `table` is reserved for internal use. Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). @@ -18,6 +21,16 @@ Height: {{ rack.u_height }}U To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. +If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: +``` +{% for server in queryset %} +{% set data = server.get_config_context() %} +{{ data.syslog }} +{% endfor %} +``` + +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. ## Example diff --git a/docs/additional-features/journaling.md b/docs/additional-features/journaling.md new file mode 100644 index 000000000..ce126bf27 --- /dev/null +++ b/docs/additional-features/journaling.md @@ -0,0 +1,5 @@ +# Journaling + +All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. + +Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index c8a3665b9..957a5a214 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -2,6 +2,13 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally. +The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met: + +* Device status is "Active" +* A primary IP has been assigned to the device +* A platform with a NAPALM driver has been assigned +* The authenticated user has the `dcim.napalm_read_device` permission + !!! note To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information. @@ -22,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. +By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 1266c22ec..bac1003fd 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo ## Writing Reports -Reports must be saved as files in the [`REPORTS_ROOT`](../../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. +Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. !!! warning The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index c66c65543..c7c8996dc 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's | ----------- | ----------- | | `{"status": "active"}` | Status is active | | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved | -| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing | +| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing | | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) | | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) | | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 | diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 23c1082bc..ee956edf5 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -12,13 +12,16 @@ NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general Use the `pg_dump` utility to export the entire database to a file: ```no-highlight -pg_dump netbox > netbox.sql +pg_dump --username netbox --password --host localhost netbox > netbox.sql ``` +!!! note + You may need to change the username, host, and/or database in the command above to match your installation. + When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. ```no-highlight -pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql +pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql ``` ### Load an Exported Database @@ -41,7 +44,7 @@ Keep in mind that PostgreSQL user accounts and permissions are not included with If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following: ```no-highlight -pg_dump -s netbox > netbox_schema.sql +pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.sql ``` --- diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 4af83493e..4ed3d946e 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -281,6 +281,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever --- +## MAPS_URL + +Default: `https://maps.google.com/?q=` (Google Maps) + +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. + +--- + ## MAX_PAGE_SIZE Default: 1000 @@ -301,7 +309,7 @@ The file path to the location where media files (such as image attachments) are Default: False -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details. +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. --- diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dba8cdc8c..3158fc73a 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes * `PASSWORD` - Redis password (if set) * `DATABASE` - Numeric database ID * `SSL` - Use SSL connection to Redis +* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended) An example configuration is provided below: diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 43b911308..51261858c 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,6 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} +{!docs/models/circuits/providernetwork.md!} --- diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 96dcf866d..e05d6efd3 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -8,6 +8,8 @@ ## Device Components +Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. + {!docs/models/dcim/consoleport.md!} {!docs/models/dcim/consoleserverport.md!} {!docs/models/dcim/powerport.md!} diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 9b2249653..beeae2ffb 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -5,8 +5,8 @@ Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need: * A Linux system or environment -* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/) -* A Redis server, which can also be [installed locally](/installation/2-redis/) +* A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md) +* A Redis server, which can also be [installed locally](../installation/2-redis.md) * A supported version of Python ### Fork the Repo diff --git a/docs/development/index.md b/docs/development/index.md index bbcb1eac8..e9758e74b 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -8,7 +8,7 @@ There are several official forums for communication among the developers and com * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. -* [#netbox on NetDev Community Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. +* [#netbox on NetDev Community Slack](https://slack.netbox.dev/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions. ## Governance diff --git a/docs/development/models.md b/docs/development/models.md new file mode 100644 index 000000000..1e9b17d22 --- /dev/null +++ b/docs/development/models.md @@ -0,0 +1,96 @@ +# NetBox Models + +## Model Types + +A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. + +### Features Matrix + +* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log +* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects +* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields +* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models +* Tagging - The models can be tagged with user-defined tags +* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary +* Nesting - These models can be nested recursively to create a hierarchy + +| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | +| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | +| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | +| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | +| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | +| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | +| Component Template | :material-check: | :material-check: | :material-check: | | | | | + +## Models Index + +### Primary Models + +* [circuits.Circuit](../models/circuits/circuit.md) +* [circuits.Provider](../models/circuits/provider.md) +* [circuits.ProviderNetwork](../models/circuits/providernetwork.md) +* [dcim.Cable](../models/dcim/cable.md) +* [dcim.Device](../models/dcim/device.md) +* [dcim.DeviceType](../models/dcim/devicetype.md) +* [dcim.PowerFeed](../models/dcim/powerfeed.md) +* [dcim.PowerPanel](../models/dcim/powerpanel.md) +* [dcim.Rack](../models/dcim/rack.md) +* [dcim.RackReservation](../models/dcim/rackreservation.md) +* [dcim.Site](../models/dcim/site.md) +* [dcim.VirtualChassis](../models/dcim/virtualchassis.md) +* [ipam.Aggregate](../models/ipam/aggregate.md) +* [ipam.IPAddress](../models/ipam/ipaddress.md) +* [ipam.Prefix](../models/ipam/prefix.md) +* [ipam.RouteTarget](../models/ipam/routetarget.md) +* [ipam.Service](../models/ipam/service.md) +* [ipam.VLAN](../models/ipam/vlan.md) +* [ipam.VRF](../models/ipam/vrf.md) +* [secrets.Secret](../models/secrets/secret.md) +* [tenancy.Tenant](../models/tenancy/tenant.md) +* [virtualization.Cluster](../models/virtualization/cluster.md) +* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) + +### Organizational Models + +* [circuits.CircuitType](../models/circuits/circuittype.md) +* [dcim.DeviceRole](../models/dcim/devicerole.md) +* [dcim.Manufacturer](../models/dcim/manufacturer.md) +* [dcim.Platform](../models/dcim/platform.md) +* [dcim.RackRole](../models/dcim/rackrole.md) +* [ipam.RIR](../models/ipam/rir.md) +* [ipam.Role](../models/ipam/role.md) +* [ipam.VLANGroup](../models/ipam/vlangroup.md) +* [secrets.SecretRole](../models/secrets/secretrole.md) +* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md) +* [virtualization.ClusterType](../models/virtualization/clustertype.md) + +### Nested Group Models + +* [dcim.Location](../models/dcim/location.md) (formerly RackGroup) +* [dcim.Region](../models/dcim/region.md) +* [dcim.SiteGroup](../models/dcim/sitegroup.md) +* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md) + +### Component Models + +* [dcim.ConsolePort](../models/dcim/consoleport.md) +* [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md) +* [dcim.DeviceBay](../models/dcim/devicebay.md) +* [dcim.FrontPort](../models/dcim/frontport.md) +* [dcim.Interface](../models/dcim/interface.md) +* [dcim.InventoryItem](../models/dcim/inventoryitem.md) +* [dcim.PowerOutlet](../models/dcim/poweroutlet.md) +* [dcim.PowerPort](../models/dcim/powerport.md) +* [dcim.RearPort](../models/dcim/rearport.md) +* [virtualization.VMInterface](../models/virtualization/vminterface.md) + +### Component Template Models + +* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md) +* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md) +* [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md) +* [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md) +* [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md) +* [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md) +* [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md) +* [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 1db490798..39008f188 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -7,8 +7,6 @@ This section entails the installation and configuration of a local PostgreSQL da ## Installation -## Installation - === "Ubuntu" ```no-highlight @@ -26,14 +24,14 @@ This section entails the installation and configuration of a local PostgreSQL da !!! info PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). -CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: + CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: -```no-highlight -host all all 127.0.0.1/32 md5 -host all all ::1/128 md5 -``` + ```no-highlight + host all all 127.0.0.1/32 md5 + host all all ::1/128 md5 + ``` -Then, start the service and enable it to run at boot: +Once PostgreSQL has been installed, start the service and enable it to run at boot: ```no-highlight sudo systemctl start postgresql diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 03e224e1f..649898e0a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -9,13 +9,13 @@ Begin by installing all system packages required by NetBox and its dependencies. !!! note NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. -=== Ubuntu +=== "Ubuntu" ```no-highlight sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` -=== CentOS +=== "CentOS" ```no-highlight sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config @@ -57,13 +57,13 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/ If `git` is not already installed, install it: -=== Ubuntu +=== "Ubuntu" ```no-highlight sudo apt install -y git ``` -=== CentOS +=== "CentOS" ```no-highlight sudo yum install -y git @@ -89,14 +89,14 @@ Checking connectivity... done. Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -=== Ubuntu +=== "Ubuntu" ``` sudo adduser --system --group netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ ``` -=== CentOS +=== "CentOS" ``` sudo groupadd --system netbox @@ -113,7 +113,7 @@ cd /opt/netbox/netbox/netbox/ sudo cp configuration.example.py configuration.py ``` -Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations: +Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` * `DATABASE` @@ -136,7 +136,7 @@ ALLOWED_HOSTS = ['*'] ### DATABASE -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters. +This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters. ```python DATABASE = { @@ -151,7 +151,7 @@ DATABASE = { ### REDIS -Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters. +Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. @@ -203,7 +203,7 @@ sudo echo napalm >> /opt/netbox/local_requirements.txt ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`. ```no-highlight sudo echo django-storages >> /opt/netbox/local_requirements.txt @@ -264,7 +264,7 @@ Quit the server with CONTROL-C. Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. -!!! warning +!!! danger The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** !!! warning diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 1c5424595..5d5135b33 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -142,7 +142,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 `systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`. -For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`: +For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`: ```python LOGGING = { diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index cdd20f01b..e824ad7ab 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -2,7 +2,7 @@ ## Review the Release Notes -Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. +Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. ## Update Dependencies to Required Versions diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index 1c0dbfe18..beea2f85a 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit termination is attached to either a site or to a provider network. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. -In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. +In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note - A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. + A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The provider network model is ideal for representing these networks. diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md new file mode 100644 index 000000000..970a9f8a8 --- /dev/null +++ b/docs/models/circuits/providernetwork.md @@ -0,0 +1,5 @@ +# Provider Network + +This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. + +Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 2210e4028..a99078472 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -8,7 +8,7 @@ A device is said to be full-depth if its installation on one rack face prevents Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.) -Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or within a site. A platform, serial number, and asset tag may optionally be assigned to each device. +Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 756e320af..bd9975a72 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -2,11 +2,15 @@ Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). -Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. +!!! note + Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. + +### Interface Types + +Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. Virtual interfaces, such as 802.1Q-tagged subinterfaces, may be assigned to physical parent interfaces. Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. -IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) +### IP Address Assignment -!!! note - Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. +IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 710d8e8fe..16df208ac 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -1,5 +1,5 @@ # Locations -Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar concept. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. +Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index af81cfbf9..bb4a22e0d 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -3,11 +3,13 @@ Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments: * Region +* Site group * Site +* Device type (devices only) * Role * Platform -* Cluster group -* Cluster +* Cluster group (VMs only) +* Cluster (VMs only) * Tenant group * Tenant * Tag diff --git a/docs/models/extras/journalentry.md b/docs/models/extras/journalentry.md deleted file mode 100644 index c95340a01..000000000 --- a/docs/models/extras/journalentry.md +++ /dev/null @@ -1,5 +0,0 @@ -# Journal Entries - -All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. - -Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created. diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index f252204c5..c7aa0d05f 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -1,6 +1,6 @@ # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into VLAN groups to define scope and to enforce uniqueness. Each VLAN must be assigned one of the following operational statuses: diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 7a0bb80ff..819d45982 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -1,5 +1,5 @@ # VLAN Groups -VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites. +VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 2b3792204..69db03724 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,71 @@ # NetBox v2.10 +## v2.10.10 (FUTURE) + +### Bug Fixes + +* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP +* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup +* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form +* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis + +--- + +## v2.10.9 (2021-04-12) + +### Enhancements + +* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list +* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view +* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color +* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant +* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis + +### Bug Fixes + +* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations +* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer +* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission +* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint +* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values +* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses +* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent +* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects + +--- + +## v2.10.8 (2021-03-26) + +### Bug Fixes + +* [#6060](https://github.com/netbox-community/netbox/issues/6060) - Fix exception on cable trace in UI (regression from #5650) + +--- + +## v2.10.7 (2021-03-25) + +### Enhancements + +* [#5641](https://github.com/netbox-community/netbox/issues/5641) - Allow filtering device components by label +* [#5723](https://github.com/netbox-community/netbox/issues/5723) - Allow customization of the geographic mapping service via `MAPS_URL` config parameter +* [#5736](https://github.com/netbox-community/netbox/issues/5736) - Allow changing site assignment when bulk editing devices +* [#5953](https://github.com/netbox-community/netbox/issues/5953) - Support Markdown rendering for custom script descriptions +* [#6040](https://github.com/netbox-community/netbox/issues/6040) - Add UI search fields for asset tag for devices and racks + +### Bug Fixes + +* [#5595](https://github.com/netbox-community/netbox/issues/5595) - Restore ability to delete an uploaded device type image +* [#5650](https://github.com/netbox-community/netbox/issues/5650) - Denote when the total length of a cable trace may exceed the indicated value +* [#5962](https://github.com/netbox-community/netbox/issues/5962) - Ensure consistent display of change log action labels +* [#5966](https://github.com/netbox-community/netbox/issues/5966) - Skip Markdown reference link when tabbing through form fields +* [#5977](https://github.com/netbox-community/netbox/issues/5977) - Correct validation of `RELEASE_CHECK_URL` config parameter +* [#6006](https://github.com/netbox-community/netbox/issues/6006) - Fix VLAN group/site association for bulk prefix import +* [#6010](https://github.com/netbox-community/netbox/issues/6010) - Eliminate duplicate virtual chassis search results +* [#6012](https://github.com/netbox-community/netbox/issues/6012) - Pre-populate attributes when creating an available child prefix via the UI +* [#6023](https://github.com/netbox-community/netbox/issues/6023) - Fix display of bottom banner with uBlock Origin enabled + +--- + ## v2.10.6 (2021-03-09) ### Enhancements @@ -19,6 +85,8 @@ * [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values * [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize` +--- + ## v2.10.5 (2021-02-24) ### Bug Fixes diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6bfdd414b..9161401ee 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -1,25 +1,63 @@ # NetBox v2.11 -## v2.11-beta1 (FUTURE) +## v2.11.0 (FUTURE) + +### Enhancements (from Beta) + +* [#5757](https://github.com/netbox-community/netbox/issues/5757) - Add unique identifier to every object view +* [#5848](https://github.com/netbox-community/netbox/issues/5848) - Filter custom fields by content type in format `.` +* [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form +* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views +* [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table +* [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces +* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page +* [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds +* [#6149](https://github.com/netbox-community/netbox/issues/6149) - Support image attachments for locations +* [#6150](https://github.com/netbox-community/netbox/issues/6150) - Enable change logging for journal entries + +### Bug Fixes (from Beta) + +* [#5583](https://github.com/netbox-community/netbox/issues/5583) - Eliminate redundant change records when adding/removing tags +* [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link +* [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table +* [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view +* [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface +* [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form +* [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export +* [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location +* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned) +* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list + +--- + +## v2.11-beta1 (2021-04-06) **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. -**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or -later will be required. +**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required. + +### Breaking Changes + +* All objects now use numeric IDs in their UI view URLs instead of slugs. You may need to update external references to NetBox objects. (Note that this does _not_ affect the REST API.) +* The UI now uses numeric IDs when filtering object lists. You may need to update external links to filtered object lists. (Note that the slug- and name-based filters will continue to work, however the filter selection fields within the UI will not be automatically populated.) +* The RackGroup model has been renamed to Location (see [#4971](https://github.com/netbox-community/netbox/issues/4971)). Its REST API endpoint has changed from `/api/dcim/rack-groups/` to `/api/dcim/locations/`. +* The foreign key field `group` on dcim.Rack has been renamed to `location`. +* The foreign key field `site` on ipam.VLANGroup has been replaced with the `scope` generic foreign key (see [#5284](https://github.com/netbox-community/netbox/issues/5284)). +* Custom script ObjectVars no longer support the `queryset` parameter: Use `model` instead (see [#5995](https://github.com/netbox-community/netbox/issues/5995)). ### New Features #### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151)) -NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. +NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after some time, journal entries persist for the life of the associated object. #### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) -Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0. +Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. #### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) -In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include a pre- and post-change representation of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: +In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include both pre- and post-change representations of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: ```json "snapshots": { @@ -38,11 +76,11 @@ In conjunction with the newly improved change logging functionality ([#5913](htt } ``` -Note: The pre-change snapshot for an object creation will always be null, as will the post-change snapshot for an object deletion. +Note: The pre-change snapshot for a newly created will always be null, as will the post-change snapshot for a deleted object. #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) -Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination. +Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where we don't necessarily know or care what is connected to an attachment point, but still need to reflect the termination as being occupied. In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. @@ -52,7 +90,7 @@ Devices can now be assigned to locations (formerly known as rack groups) within #### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999)) -When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns. +When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen. The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12. @@ -64,33 +102,47 @@ For example, a VLAN within a group assigned to a location will be available only #### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) -This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. +This release introduces the new SiteGroup model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by functional role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. #### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. +#### Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +A new provider network model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. Each instance of this model must be assigned to a provider, and circuits can now terminate to either provider networks or to sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. + ### Enhancements +* [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type +* [#5344](https://github.com/netbox-community/netbox/issues/5344) - Add support for custom fields in tables * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models +* [#5425](https://github.com/netbox-community/netbox/issues/5425) - Create separate tabs for VMs and devices under the cluster view * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields * [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links * [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks +* [#5830](https://github.com/netbox-community/netbox/issues/5830) - Add `as_attachment` to ExportTemplate to control download behavior * [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models +* [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models * [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models -* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial vCPU allocations for virtual machines +* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines +* [#6001](https://github.com/netbox-community/netbox/issues/6001) - Paginate component tables under device views +* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view ### Other Changes * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers * [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs +* [#5938](https://github.com/netbox-community/netbox/issues/5938) - Deprecated support for Python 3.6 * [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields * [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) +* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view +* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits ### REST API Changes @@ -108,6 +160,11 @@ The ObjectChange model (which is used to record the creation, modification, and * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied * Renamed RackGroup to Location * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* circuits.CircuitTermination + * Added the `provider_network` field + * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields +* circuits.ProviderNetwork + * Added the `/api/circuits/provider-networks/` endpoint * dcim.Device * Added the `location` field * dcim.Interface @@ -126,6 +183,8 @@ The ObjectChange model (which is used to record the creation, modification, and * Added new custom field type: `multi-select` * extras.CustomLink * Added the `/api/extras/custom-links/` endpoint +* extras.ExportTemplate + * Added the `as_attachment` boolean field * extras.ObjectChange * Added the `prechange_data` field * Renamed `object_data` to `postchange_data` @@ -136,3 +195,5 @@ The ObjectChange model (which is used to record the creation, modification, and * Dropped the `site` foreign key field * virtualization.VirtualMachine * `vcpus` has been changed from an integer to a decimal value +* virtualization.VMInterface + * Added the `parent` field diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 5d5777483..7fb789e0f 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -20,7 +20,7 @@ http://netbox/api/dcim/sites/ } ``` -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: ``` $ curl http://netbox/api/dcim/sites/ diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 290343aa6..088286e22 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects. ### Excluding Config Contexts -When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. +When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. ## Pagination @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://netbox/api/dcim/devices/?limit=100 @@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a } ``` -The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. !!! warning Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. @@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.' ### Creating a New Object -To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. +To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. ```no-highlight curl -s -X POST \ diff --git a/docs/rest-api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md index dafbb7239..5fbbf7355 100644 --- a/docs/rest-api/working-with-secrets.md +++ b/docs/rest-api/working-with-secrets.md @@ -4,7 +4,7 @@ As with most other objects, the REST API can be used to view, create, modify, an ## Generating a Session Key -In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`. +In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`. ```no-highlight $ curl -X POST http://netbox/api/secrets/get-session-key/ \ diff --git a/mkdocs.yml b/mkdocs.yml index 233a61fdf..fb5cf1890 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,9 @@ markdown_extensions: - admonition - markdown_include.include: headingOffset: 1 + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg - pymdownx.superfences - pymdownx.tabbed nav: @@ -50,6 +53,7 @@ nav: - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' + - Journaling: 'additional-features/journaling.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' @@ -71,11 +75,13 @@ nav: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' - Style Guide: 'development/style-guide.md' + - Models: 'development/models.md' - Extending Models: 'development/extending-models.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.11: 'release-notes/version-2.11.md' - Version 2.10: 'release-notes/version-2.10.md' - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 7c7d371ad..fccf4a8b6 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,16 +1,29 @@ from rest_framework import serializers -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', 'NestedCircuitTerminationSerializer', 'NestedCircuitTypeSerializer', + 'NestedProviderNetworkSerializer', 'NestedProviderSerializer', ] +# +# Provider networks +# + +class NestedProviderNetworkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + + class Meta: + model = Provider + fields = ['id', 'url', 'display', 'name'] + + # # Providers # diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index bae45e2b3..014ec0fc8 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices -from circuits.models import Provider, Circuit, CircuitTermination, CircuitType +from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from netbox.api import ChoiceField @@ -28,6 +28,22 @@ class ProviderSerializer(PrimaryModelSerializer): ] +# +# Provider networks +# + +class ProviderNetworkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + provider = NestedProviderSerializer() + + class Meta: + model = ProviderNetwork + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + # # Circuits # @@ -44,15 +60,15 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): ] -class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): +class CircuitCircuitTerminationSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() + provider_network = NestedProviderNetworkSerializer() class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', + 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', ] @@ -74,16 +90,17 @@ class CircuitSerializer(PrimaryModelSerializer): ] -class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() - site = NestedSiteSerializer() + site = NestedSiteSerializer(required=False) + provider_network = NestedProviderNetworkSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + '_occupied', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b496796fe..5389e0bde 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet) router.register('circuits', views.CircuitViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet) +# Provider networks +router.register('provider-networks', views.ProviderNetworkViewSet) + app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index c2fe3d089..c037bc5fd 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,8 +1,7 @@ -from django.db.models import Prefetch from rest_framework.routers import APIRootView from circuits import filters -from circuits.models import Provider, CircuitTermination, CircuitType, Circuit +from circuits.models import * from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet @@ -48,8 +47,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), - 'type', 'tenant', 'provider', + 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet @@ -61,8 +59,18 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', '_path__destination', 'cable' + 'circuit', 'site', 'provider_network', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet brief_prefetch_fields = ['circuit'] + + +# +# Provider networks +# + +class ProviderNetworkViewSet(CustomFieldModelViewSet): + queryset = ProviderNetwork.objects.prefetch_related('tags') + serializer_class = serializers.ProviderNetworkSerializer + filterset_class = filters.ProviderNetworkFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 03da662e7..f5d81c7bd 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -9,12 +9,13 @@ from utilities.filters import ( BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter ) from .choices import * -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * __all__ = ( 'CircuitFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', + 'ProviderNetworkFilterSet', 'ProviderFilterSet', ) @@ -79,6 +80,36 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated ) +class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + provider_id = django_filters.ModelMultipleChoiceFilter( + queryset=Provider.objects.all(), + label='Provider (ID)', + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label='Provider (slug)', + ) + tag = TagFilter() + + class Meta: + model = ProviderNetwork + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + + class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: @@ -101,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) + provider_network_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__provider_network', + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', + ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), label='Circuit type (ID)', @@ -171,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -190,6 +226,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) + provider_network_id = django_filters.ModelMultipleChoiceFilter( + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', + ) class Meta: model = CircuitTermination diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 002c73b9a..1b3eb3242 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -14,7 +14,7 @@ from utilities.forms import ( StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -128,6 +128,83 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): tag = TagFilterField(model) +# +# Provider networks +# + +class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', 'tags', + ] + fieldsets = ( + ('Provider Network', ('provider', 'name', 'description', 'tags')), + ) + + +class ProviderNetworkCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', + ] + + +class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + widget=forms.MultipleHiddenInput + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'description', 'comments', + ] + + +class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = ProviderNetwork + field_order = ['q', 'provider_id'] + q = forms.CharField( + required=False, + label=_('Search') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') + ) + tag = TagFilterField(model) + + # # Circuit types # @@ -280,7 +357,8 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type_id', 'provider_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', 'commit_rate', + 'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'commit_rate', ] q = forms.CharField( required=False, @@ -296,6 +374,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label=_('Provider') ) + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Provider network') + ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False, @@ -346,14 +432,19 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): query_params={ 'region_id': '$region', 'group_id': '$site_group', - } + }, + required=False + ) + provider_network = DynamicModelChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', + 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -365,3 +456,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0027_providernetwork.py b/netbox/circuits/migrations/0027_providernetwork.py new file mode 100644 index 000000000..e8fbdb8d4 --- /dev/null +++ b/netbox/circuits/migrations/0027_providernetwork.py @@ -0,0 +1,65 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0058_journalentry'), + ('circuits', '0026_mark_connected'), + ] + + operations = [ + # Create the new ProviderNetwork model + migrations.CreateModel( + name='ProviderNetwork', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('provider', 'name'), + }, + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_provider_name'), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together={('provider', 'name')}, + ), + + # Add ProviderNetwork FK to CircuitTermination + migrations.AddField( + model_name='circuittermination', + name='provider_network', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.providernetwork'), + ), + migrations.AlterField( + model_name='circuittermination', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), + ), + + # Add FKs to CircuitTermination on Circuit + migrations.AddField( + model_name='circuit', + name='termination_a', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + migrations.AddField( + model_name='circuit', + name='termination_z', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + ] diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py new file mode 100644 index 000000000..23734348e --- /dev/null +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -0,0 +1,37 @@ +import sys + +from django.db import migrations + + +def cache_circuit_terminations(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Caching circuit terminations...", flush=True) + + a_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A') + } + z_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z') + } + for circuit in Circuit.objects.all(): + Circuit.objects.filter(pk=circuit.pk).update( + termination_a_id=a_terminations.get(circuit.pk), + termination_z_id=z_terminations.get(circuit.pk), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0027_providernetwork'), + ] + + operations = [ + migrations.RunPython( + code=cache_circuit_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/migrations/0029_circuit_tracing.py b/netbox/circuits/migrations/0029_circuit_tracing.py new file mode 100644 index 000000000..bddb38bb6 --- /dev/null +++ b/netbox/circuits/migrations/0029_circuit_tracing.py @@ -0,0 +1,32 @@ +from django.db import migrations +from django.db.models import Q + + +def delete_obsolete_cablepaths(apps, schema_editor): + """ + Delete all CablePath instances which originate or terminate at a CircuitTermination. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + CablePath = apps.get_model('dcim', 'CablePath') + + ct = ContentType.objects.get_for_model(CircuitTermination) + CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0028_cache_circuit_terminations'), + ] + + operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='_path', + ), + migrations.RunPython( + code=delete_obsolete_cablepaths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index d19841e4f..b2ffb3c09 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -8,13 +9,13 @@ from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .choices import * -from .querysets import CircuitQuerySet __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', + 'ProviderNetwork', 'Provider', ) @@ -91,6 +92,63 @@ class Provider(PrimaryModel): ) +# +# Provider networks +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ProviderNetwork(PrimaryModel): + """ + This represents a provider network which exists outside of NetBox, the details of which are unknown or + unimportant to the user. + """ + name = models.CharField( + max_length=100 + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='networks' + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + csv_headers = [ + 'provider', 'name', 'description', 'comments', + ] + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('provider', 'name') + constraints = ( + models.UniqueConstraint( + fields=('provider', 'name'), + name='circuits_providernetwork_provider_name' + ), + ) + unique_together = ('provider', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:providernetwork', args=[self.pk]) + + def to_csv(self): + return ( + self.provider.name, + self.name, + self.description, + self.comments, + ) + + @extras_features('custom_fields', 'export_templates', 'webhooks') class CircuitType(OrganizationalModel): """ @@ -121,7 +179,7 @@ class CircuitType(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + return reverse('circuits:circuittype', args=[self.pk]) def to_csv(self): return ( @@ -181,7 +239,25 @@ class Circuit(PrimaryModel): blank=True ) - objects = CircuitQuerySet.as_manager() + # Cache associated CircuitTerminations + termination_a = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + termination_z = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -216,23 +292,9 @@ class Circuit(PrimaryModel): def get_status_class(self): return CircuitStatusChoices.CSS_CLASSES.get(self.status) - def _get_termination(self, side): - for ct in self.terminations.all(): - if ct.term_side == side: - return ct - return None - - @property - def termination_a(self): - return self._get_termination('A') - - @property - def termination_z(self): - return self._get_termination('Z') - @extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): +class CircuitTermination(ChangeLoggedModel, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -246,7 +308,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, - related_name='circuit_terminations' + related_name='circuit_terminations', + blank=True, + null=True + ) + provider_network = models.ForeignKey( + to=ProviderNetwork, + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)', @@ -281,7 +352,21 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + return f'Termination {self.term_side}: {self.site or self.provider_network}' + + def get_absolute_url(self): + if self.site: + return self.site.get_absolute_url() + return self.provider_network.get_absolute_url() + + def clean(self): + super().clean() + + # Must define either site *or* provider network + if self.site is None and self.provider_network is None: + raise ValidationError("A circuit termination must attach to either a site or a provider network.") + if self.site and self.provider_network: + raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): # Annotate the parent Circuit diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py deleted file mode 100644 index 8a9bd50a4..000000000 --- a/netbox/circuits/querysets.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db.models import OuterRef, Subquery - -from utilities.querysets import RestrictedQuerySet - - -class CircuitQuerySet(RestrictedQuerySet): - - def annotate_sites(self): - """ - Annotate the A and Z termination site names for ordering. - """ - from circuits.models import CircuitTermination - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - return self.annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db21400..0a000fb2e 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -2,16 +2,28 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone +from dcim.signals import rebuild_paths from .models import Circuit, CircuitTermination -@receiver((post_save, post_delete), sender=CircuitTermination) +@receiver(post_save, sender=CircuitTermination) def update_circuit(instance, **kwargs): """ - When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. + When a CircuitTermination has been modified, update its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) - time = timezone.now() - for circuit in circuits: - circuit.last_updated = time - circuit.save() + fields = { + 'last_updated': timezone.now(), + f'termination_{instance.term_side.lower()}': instance.pk, + } + Circuit.objects.filter(pk=instance.circuit_id).update(**fields) + + +@receiver((post_save, post_delete), sender=CircuitTermination) +def rebuild_cablepaths(instance, raw=False, **kwargs): + """ + Rebuild any CablePaths which traverse the peer CircuitTermination. + """ + if not raw: + peer_termination = instance.get_peer_termination() + if peer_termination: + rebuild_paths(peer_termination) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index efa7e4c49..41a3aed7f 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,7 +3,16 @@ from django_tables2.utils import Accessor from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .models import Circuit, CircuitType, Provider +from .models import * + + +CIRCUITTERMINATION_LINK = """ +{% if value.site %} + {{ value.site }} +{% elif value.provider_network %} + {{ value.provider_network }} +{% endif %} +""" # @@ -12,7 +21,9 @@ from .models import Circuit, CircuitType, Provider class ProviderTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( accessor=Accessor('count_circuits'), verbose_name='Circuits' @@ -29,13 +40,37 @@ class ProviderTable(BaseTable): default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') +# +# Provider networks +# + +class ProviderNetworkTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='circuits:providernetwork_list' + ) + + class Meta(BaseTable.Meta): + model = ProviderNetwork + fields = ('pk', 'name', 'provider', 'description', 'tags') + default_columns = ('pk', 'name', 'provider', 'description') + + # # Circuit types # class CircuitTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( verbose_name='Circuits' ) @@ -53,7 +88,8 @@ class CircuitTypeTable(BaseTable): class CircuitTable(BaseTable): pk = ToggleColumn() - cid = tables.LinkColumn( + cid = tables.Column( + linkify=True, verbose_name='ID' ) provider = tables.Column( @@ -61,11 +97,13 @@ class CircuitTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - a_side = tables.Column( - verbose_name='A Side' + termination_a = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side A' ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -74,7 +112,9 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', - 'description', 'tags', + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'tags', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) - default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 3341c72c3..424b13d40 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from circuits.choices import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Site from utilities.testing import APITestCase, APIViewTestCases @@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): cls.bulk_update_data = { 'port_speed': 123456 } + + +class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): + model = ProviderNetwork + brief_fields = ['display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + + cls.create_data = [ + { + 'name': 'Provider Network 4', + 'provider': providers[0].pk, + }, + { + 'name': 'Provider Network 5', + 'provider': providers[0].pk, + }, + { + 'name': 'Provider Network 6', + 'provider': providers[0].pk, + }, + ] + + cls.bulk_update_data = { + 'provider': providers[1].pk, + 'description': 'New description', + } diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index b9e1eac45..448e42368 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -2,7 +2,7 @@ from django.test import TestCase from circuits.choices import * from circuits.filters import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Cable, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup @@ -186,6 +186,13 @@ class CircuitTestCase(TestCase): ) Provider.objects.bulk_create(providers) + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[1]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[1]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + circuits = ( Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE), @@ -200,6 +207,9 @@ class CircuitTestCase(TestCase): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -226,6 +236,11 @@ class CircuitTestCase(TestCase): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): circuit_type = CircuitType.objects.first() params = {'type_id': [circuit_type.pk]} @@ -281,14 +296,14 @@ class CircuitTerminationTestCase(TestCase): def setUpTestData(cls): sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) circuit_types = ( - CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'), + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), ) CircuitType.objects.bulk_create(circuit_types) @@ -297,10 +312,20 @@ class CircuitTerminationTestCase(TestCase): ) Provider.objects.bulk_create(providers) + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + circuits = ( - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), ) Circuit.objects.bulk_create(circuits) @@ -311,6 +336,9 @@ class CircuitTerminationTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -318,7 +346,7 @@ class CircuitTerminationTestCase(TestCase): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -344,12 +372,48 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} + +class ProviderNetworkTestCase(TestCase): + queryset = ProviderNetwork.objects.all() + filterset = ProviderNetworkFilterSet + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[2]), + ) + ProviderNetwork.objects.bulk_create(provider_networks) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Provider Network 1', 'Provider Network 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'provider': [providers[0].slug, providers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index de0d2c970..62e3e3a22 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,8 +1,12 @@ import datetime +from django.test import override_settings +from django.urls import reverse + from circuits.choices import * -from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import ViewTestCases +from circuits.models import * +from dcim.models import Cable, Interface, Site +from utilities.testing import ViewTestCases, create_test_device class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -133,3 +137,99 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', 'comments': 'New comments', } + + +class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ProviderNetwork + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + ProviderNetwork.objects.bulk_create([ + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ]) + + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Provider Network X', + 'provider': providers[1].pk, + 'description': 'A new provider network', + 'comments': 'Longer description goes here', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,provider,description", + "Provider Network 4,Provider 1,Foo", + "Provider Network 5,Provider 1,Bar", + "Provider Network 6,Provider 1,Baz", + ) + + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'description': 'New description', + 'comments': 'New comments', + } + + +class CircuitTerminationTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, +): + model = CircuitTermination + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + + circuits = ( + Circuit(cid='Circuit 1', provider=provider, type=circuittype), + Circuit(cid='Circuit 2', provider=provider, type=circuittype), + Circuit(cid='Circuit 3', provider=provider, type=circuittype), + ) + Circuit.objects.bulk_create(circuits) + + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), + CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), + ) + CircuitTermination.objects.bulk_create(circuit_terminations) + + cls.form_data = { + 'term_side': 'A', + 'site': sites[2].pk, + 'description': 'New description', + } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + device = create_test_device('Device 1') + + circuittermination = CircuitTermination.objects.first() + interface = Interface.objects.create( + device=device, + name='Interface 1' + ) + Cable(termination_a=circuittermination, termination_b=interface).save() + + response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk})) + self.assertHttpStatus(response, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 0b47b4b2c..1cea1965e 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,8 +2,9 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView, ObjectJournalView +from utilities.views import SlugRedirectView from . import views -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * app_name = 'circuits' urlpatterns = [ @@ -15,17 +16,31 @@ urlpatterns = [ path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + # Provider networks + path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), + path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'), + path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'), + path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'), + path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'), + path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), + path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), + path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), + path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), + path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), + # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b3215c029..92e53c30f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,15 +1,15 @@ 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 django_tables2 import RequestConfig from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import count_related from . import filters, forms, tables from .choices import CircuitTerminationSideChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -33,16 +33,11 @@ class ProviderView(generic.ObjectView): provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' - ).annotate_sites() + ) circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(circuits_table) + paginate_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -81,6 +76,66 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable +# +# Provider networks +# + +class ProviderNetworkListView(generic.ObjectListView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + filterset_form = forms.ProviderNetworkFilterForm + table = tables.ProviderNetworkTable + + +class ProviderNetworkView(generic.ObjectView): + queryset = ProviderNetwork.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + Q(termination_a__provider_network=instance.pk) | + Q(termination_z__provider_network=instance.pk) + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('termination_a') + circuits_table.columns.hide('termination_z') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + +class ProviderNetworkEditView(generic.ObjectEditView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkForm + + +class ProviderNetworkDeleteView(generic.ObjectDeleteView): + queryset = ProviderNetwork.objects.all() + + +class ProviderNetworkBulkImportView(generic.BulkImportView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkCSVForm + table = tables.ProviderNetworkTable + + +class ProviderNetworkBulkEditView(generic.BulkEditView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable + form = forms.ProviderNetworkBulkEditForm + + +class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable + + # # Circuit Types # @@ -92,6 +147,23 @@ class CircuitTypeListView(generic.ObjectListView): table = tables.CircuitTypeTable +class CircuitTypeView(generic.ObjectView): + queryset = CircuitType.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + type=instance + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('type') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm @@ -129,8 +201,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' - ).annotate_sites() + 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + ) filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable @@ -147,8 +219,6 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): - termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') # Z-side termination termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( @@ -156,8 +226,6 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() - if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): - termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') return { 'termination_a': termination_a, diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4941b7bbc..e9efe4136 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -3,13 +3,14 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, @@ -106,7 +107,7 @@ class SiteSerializer(PrimaryModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneField(required=False) + time_zone = TimeZoneSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -133,12 +134,13 @@ class LocationSerializer(NestedGroupModelSerializer): site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) class Meta: model = Location fields = [ 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', - 'last_updated', 'rack_count', '_depth', + 'last_updated', 'rack_count', 'device_count', '_depth', ] @@ -840,7 +842,7 @@ class CablePathSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = serializers.SerializerMethodField() - interface_b = NestedInterfaceSerializer(source='connected_endpoint') + interface_b = NestedInterfaceSerializer(source='_path.destination') connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) class Meta: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 6aa4fd484..cb46c1eca 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,6 +2,7 @@ import socket from collections import OrderedDict from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db.models import F from django.http import HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404 @@ -141,12 +142,18 @@ class SiteViewSet(CustomFieldModelViewSet): # -# Rack groups +# Locations # class LocationViewSet(CustomFieldModelViewSet): queryset = Location.objects.add_related_count( - Location.objects.all(), + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, 'location', 'rack_count', @@ -174,7 +181,7 @@ class RackRoleViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( - 'site', 'location__site', 'role', 'tenant', 'tags' + 'site', 'location', 'role', 'tenant', 'tags' ).annotate( device_count=count_related(Device, 'rack'), powerfeed_count=count_related(PowerFeed, 'rack') @@ -590,6 +597,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): queryset = Interface.objects.prefetch_related('device', '_path').filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair + _path__destination_type__app_label='dcim', + _path__destination_type__model='interface', _path__destination_id__isnull=False, pk__lt=F('_path__destination_id') ) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 8d19dc2f0..202a50cb4 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -191,15 +191,18 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Site (slug)', ) - parent_id = django_filters.ModelMultipleChoiceFilter( + parent_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), - label='Rack group (ID)', + field_name='parent', + lookup_expr='in', + label='Location (ID)', ) - parent = django_filters.ModelMultipleChoiceFilter( - field_name='parent__slug', + parent = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), + field_name='parent', + lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) class Meta: @@ -857,7 +860,7 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina class Meta: model = ConsolePort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class ConsoleServerPortFilterSet( @@ -873,7 +876,7 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -884,7 +887,7 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = PowerPort - fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] + fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -895,7 +898,7 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina class Meta: model = PowerOutlet - fields = ['id', 'name', 'feed_leg', 'description'] + fields = ['id', 'name', 'label', 'feed_leg', 'description'] class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -946,7 +949,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = Interface - fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def filter_device(self, queryset, name, value): try: @@ -1000,21 +1003,21 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = FrontPort - fields = ['id', 'name', 'type', 'description'] + fields = ['id', 'name', 'label', 'type', 'description'] class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = RearPort - fields = ['id', 'name', 'type', 'positions', 'description'] + fields = ['id', 'name', 'label', 'type', 'positions', 'description'] class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): @@ -1075,7 +1078,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): @@ -1090,7 +1093,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(BaseFilterSet): +class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1167,10 +1170,10 @@ class VirtualChassisFilterSet(BaseFilterSet): Q(members__name__icontains=value) | Q(domain__icontains=value) ) - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() -class CableFilterSet(BaseFilterSet): +class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1346,7 +1349,7 @@ class PowerPanelFilterSet(BaseFilterSet): queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) tag = TagFilter() diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 76de832da..feb8c5e81 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -56,12 +56,18 @@ def get_device_by_name_or_pk(name): class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ - 'q', 'region_id', 'site_group_id', 'site_id' + 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', ] q = forms.CharField( required=False, label=_('Search') ) + name = forms.CharField( + required=False + ) + label = forms.CharField( + required=False + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -91,6 +97,11 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): class InterfaceCommonForm(forms.Form): + mac_address = forms.CharField( + empty_value=None, + required=False, + label='MAC address' + ) def clean(self): super().clean() @@ -298,6 +309,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) slug = SlugField() + time_zone = TimeZoneFormField( + choices=add_blank_choice(TimeZoneFormField().choices), + required=False, + widget=StaticSelect2() + ) comments = CommentField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -505,9 +521,9 @@ class LocationCSVForm(CustomFieldModelCSVForm): queryset=Location.objects.all(), required=False, to_field_name='name', - help_text='Parent rack group', + help_text='Parent location', error_messages={ - 'invalid_choice': 'Rack group not found.', + 'invalid_choice': 'Location not found.', } ) @@ -555,7 +571,7 @@ class LocationFilterForm(BootstrapMixin, forms.Form): }, label=_('Site') ) - parent = DynamicModelMultipleChoiceField( + parent_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, query_params={ @@ -880,6 +896,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): null_option='None', label=_('Role') ) + asset_tag = forms.CharField( + required=False + ) tag = TagFilterField(model) @@ -940,7 +959,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Rack.objects.all(), query_params={ 'site_id': '$site', - 'location_id': 'location', + 'location_id': '$location', } ) units = NumericArrayField( @@ -1045,7 +1064,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RackReservation field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( @@ -1068,13 +1087,13 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, - label='Location', + label=_('Location'), null_option='None' ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, - label='User', + label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', ) @@ -1149,10 +1168,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): widgets = { 'subdevice_role': StaticSelect2(), # Exclude SVG images (unsupported by PIL) - 'front_image': forms.FileInput(attrs={ + 'front_image': forms.ClearableFileInput(attrs={ 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' }), - 'rear_image': forms.FileInput(attrs={ + 'rear_image': forms.ClearableFileInput(attrs={ 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' }) } @@ -2017,7 +2036,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, query_params={ 'region_id': '$region', 'group_id': '$site_group', @@ -2038,7 +2056,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False, query_params={ 'site_id': '$site', - 'location_id': 'location', + 'location_id': '$location', } ) position = forms.IntegerField( @@ -2104,8 +2122,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', - 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', - 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', + 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -2344,6 +2362,17 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=DeviceRole.objects.all(), required=False ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -2373,7 +2402,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt model = Device field_order = [ 'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', - 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip', ] q = forms.CharField( required=False, @@ -2381,14 +2410,16 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - required=False + required=False, + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, query_params={ 'region_id': '$region_id' - } + }, + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -2437,6 +2468,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, widget=StaticSelect2Multiple() ) + asset_tag = forms.CharField( + required=False + ) mac_address = forms.CharField( required=False, label='MAC address' @@ -3038,10 +3072,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface', - query_params={ - 'kind': 'physical', - } + label='Parent interface' ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -3054,20 +3085,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Tagged VLANs' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -3101,9 +3124,9 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): self.fields['parent'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): @@ -3121,7 +3144,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, query_params={ 'device_id': '$device', - 'kind': 'physical', } ) lag = DynamicModelChoiceField( @@ -3154,19 +3176,11 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', @@ -3176,12 +3190,10 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Add current site to VLANs query params - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + device_id = self.initial.get('device') or self.data.get('device') + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) class InterfaceBulkCreateForm( @@ -3218,10 +3230,7 @@ class InterfaceBulkEditForm( ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), - required=False, - query_params={ - 'kind': 'physical', - } + required=False ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -3241,19 +3250,11 @@ class InterfaceBulkEditForm( ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) class Meta: @@ -3270,9 +3271,9 @@ class InterfaceBulkEditForm( self.fields['parent'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) else: # See #4523 @@ -4322,7 +4323,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo }) -class CableFilterForm(BootstrapMixin, forms.Form): +class CableFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cable q = forms.CharField( required=False, @@ -4349,7 +4350,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', + label=_('Rack'), null_option='None', query_params={ 'site_id': '$site_id' @@ -4743,7 +4744,6 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, query_params={ 'region_id': '$region', 'group_id': '$site_group', @@ -4767,7 +4767,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'region', 'site_group', 'site', 'location', 'name', 'tags', ] fieldsets = ( - ('Power Panel', ('region', 'site', 'location', 'name', 'tags')), + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), ) diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 06b5bdec0..fd5f9cfab 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand from django.core.management.color import no_style from django.db import connection -from circuits.models import CircuitTermination from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.signals import create_cablepath ENDPOINT_MODELS = ( - CircuitTermination, ConsolePort, ConsoleServerPort, Interface, diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c8166cb44..28d21ff68 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -242,6 +242,16 @@ class Cable(PrimaryModel): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None: + raise ValidationError({ + 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled." + }) + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None: + raise ValidationError({ + 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled." + }) + # Check for an existing Cable connected to either termination object if self.termination_a.cable not in (None, self): raise ValidationError("{} already has a cable attached (#{})".format( @@ -384,6 +394,8 @@ class CablePath(BigIDModel): """ Create a new CablePath instance as traced from the given path origin. """ + from circuits.models import CircuitTermination + if origin is None or origin.cable is None: return None @@ -431,6 +443,23 @@ class CablePath(BigIDModel): # No corresponding FrontPort found for the RearPort break + # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) + elif isinstance(peer_termination, CircuitTermination): + path.append(object_to_path_node(peer_termination)) + # Get peer CircuitTermination + node = peer_termination.get_peer_termination() + if node: + path.append(object_to_path_node(node)) + if node.provider_network: + destination = node.provider_network + break + elif node.site and not node.cable: + destination = node.site + break + else: + # No peer CircuitTermination exists; halt the trace + break + # Anything else marks the end of the path else: destination = peer_termination @@ -476,19 +505,43 @@ class CablePath(BigIDModel): return path + @property + def last_node(self): + """ + Return either the destination or the last node within the path. + """ + return self.destination or path_node_to_object(self.path[-1]) + + def get_cable_ids(self): + """ + Return all Cable IDs within the path. + """ + cable_ct = ContentType.objects.get_for_model(Cable).pk + cable_ids = [] + + for node in self.path: + ct, id = decompile_path_node(node) + if ct == cable_ct: + cable_ids.append(id) + + return cable_ids + def get_total_length(self): """ - Return the sum of the length of each cable in the path. + Return a tuple containing the sum of the length of each cable in the path + and a flag indicating whether the length is definitive. """ - cable_ids = [ - # Starting from the first element, every third element in the path should be a Cable - decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) - ] - return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] + cable_ids = self.get_cable_ids() + cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) + total_length = cables.aggregate(total=Sum('_abs_length'))['total'] + is_definitive = len(cables) == len(cable_ids) + + return total_length, is_definitive def get_split_nodes(self): """ Return all available next segments in a split cable path. """ rearport = path_node_to_object(self.path[-1]) + return FrontPort.objects.filter(rear_port=rearport) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4b162b30f..eda262a59 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -141,7 +141,9 @@ class CableTermination(models.Model): super().clean() if self.mark_connected and self.cable_id: - raise ValidationError("Cannot set mark_connected with a cable connected.") + raise ValidationError({ + "mark_connected": "Cannot mark as connected with a cable attached." + }) def get_cable_peer(self): return self._cable_peer @@ -158,7 +160,7 @@ class CableTermination(models.Model): class PathEndpoint(models.Model): """ An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, - these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination. + these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the @@ -182,10 +184,11 @@ class PathEndpoint(models.Model): # Construct the complete path path = [self, *self._path.get_path()] - while (len(path) + 1) % 3: + if self._path.destination: + path.append(self._path.destination) + while len(path) % 3: # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) - path.append(None) - path.append(self._path.destination) + path.insert(-1, None) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) @@ -504,6 +507,10 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) + @property + def count_ipaddresses(self): + return self.ip_addresses.count() + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): @@ -596,12 +603,15 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): super().clean() # Virtual interfaces cannot be connected - if self.type in NONCONNECTABLE_IFACE_TYPES and ( - self.cable or getattr(self, 'circuit_termination', False) - ): + if not self.is_connectable and self.cable: raise ValidationError({ - 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable type." + 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." + }) + + # Non-connectable interfaces cannot be marked as connected + if not self.is_connectable and self.mark_connected: + raise ValidationError({ + 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) # An interface's parent must belong to the same device or virtual chassis @@ -617,14 +627,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): f"is not part of virtual chassis {self.device.virtual_chassis}." }) + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # A physical interface cannot have a parent interface if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) - # A virtual interface cannot be a parent interface - if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL: - raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."}) - # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: @@ -668,10 +678,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG - @property - def count_ipaddresses(self): - return self.ip_addresses.count() - # # Pass-through ports diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 7f22d9325..551fac2d4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -65,7 +65,7 @@ class Manufacturer(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + return reverse('dcim:manufacturer', args=[self.pk]) def to_csv(self): return ( @@ -375,6 +375,9 @@ class DeviceRole(OrganizationalModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:devicerole', args=[self.pk]) + def to_csv(self): return ( self.name, @@ -436,7 +439,7 @@ class Platform(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + return reverse('dcim:platform', args=[self.pk]) def to_csv(self): return ( @@ -649,6 +652,10 @@ class Device(PrimaryModel, ConfigContextModel): raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to site {self.site}.", }) + if self.location and self.site != self.location.site: + raise ValidationError({ + 'location': f"Location {self.location} does not belong to site {self.site}.", + }) if self.rack and self.location and self.rack.location != self.location: raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to location {self.location}.", diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 06d234149..a5e3149f8 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -66,9 +66,9 @@ class PowerPanel(PrimaryModel): # Location must belong to assigned Site if self.location and self.location.site != self.site: - raise ValidationError("Rack group {} ({}) is in a different site than {}".format( - self.location, self.location.site, self.site - )) + raise ValidationError( + f"Location {self.location} ({self.location.site}) is in a different site than {self.site}" + ) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 1942e3cb0..2869c4265 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -67,7 +67,7 @@ class RackRole(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + return reverse('dcim:rackrole', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 51cb63d08..225a8e749 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,6 +7,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * +from django.core.exceptions import ValidationError from dcim.fields import ASNField from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel @@ -56,7 +57,7 @@ class Region(NestedGroupModel): csv_headers = ['name', 'slug', 'parent', 'description'] def get_absolute_url(self): - return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:region', args=[self.pk]) def to_csv(self): return ( @@ -108,7 +109,7 @@ class SiteGroup(NestedGroupModel): csv_headers = ['name', 'slug', 'parent', 'description'] def get_absolute_url(self): - return "{}?group={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:sitegroup', args=[self.pk]) def to_csv(self): return ( @@ -313,8 +314,12 @@ class Location(NestedGroupModel): max_length=200, blank=True ) + images = GenericRelation( + to='extras.ImageAttachment' + ) csv_headers = ['site', 'parent', 'name', 'slug', 'description'] + clone_fields = ['site', 'parent', 'description'] class Meta: ordering = ['site', 'name'] @@ -324,7 +329,7 @@ class Location(NestedGroupModel): ] def get_absolute_url(self): - return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk) + return reverse('dcim:location', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index cdb79f4e1..9509ec2bc 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -26,9 +26,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side A' ) - termination_a = tables.LinkColumn( + termination_a = tables.Column( accessor=Accessor('termination_a'), orderable=False, + linkify=True, verbose_name='Termination A' ) termination_b_parent = tables.TemplateColumn( @@ -37,9 +38,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side B' ) - termination_b = tables.LinkColumn( + termination_b = tables.Column( accessor=Accessor('termination_b'), orderable=False, + linkify=True, verbose_name='Termination B' ) status = ChoiceFieldColumn() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c4204dd4a..cc1d78fa3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -44,20 +44,31 @@ __all__ = ( ) +def get_cabletermination_row_class(record): + if record.mark_connected: + return 'success' + elif record.cable: + return record.cable.get_status_class() + return '' + + # # Device roles # class DeviceRoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='VMs' ) color = ColorColumn() @@ -76,14 +87,17 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='VMs' ) actions = ButtonsColumn(Platform) @@ -123,11 +137,13 @@ class DeviceTable(BaseTable): device_role = ColoredLabelColumn( verbose_name='Role' ) - device_type = tables.LinkColumn( - viewname='dcim:devicetype', - args=[Accessor('device_type__pk')], - verbose_name='Type', - text=lambda record: record.device_type.display_name + manufacturer = tables.Column( + accessor=Accessor('device_type__manufacturer'), + linkify=True + ) + device_type = tables.Column( + linkify=True, + verbose_name='Type' ) if settings.PREFER_IPV4: primary_ip = tables.Column( @@ -149,13 +165,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='IPv6 Address' ) - cluster = tables.LinkColumn( - viewname='virtualization:cluster', - args=[Accessor('cluster__pk')] + cluster = tables.Column( + linkify=True ) - virtual_chassis = tables.LinkColumn( - viewname='dcim:virtualchassis', - args=[Accessor('virtual_chassis__pk')] + virtual_chassis = tables.Column( + linkify=True ) vc_position = tables.Column( verbose_name='VC Position' @@ -170,12 +184,13 @@ class DeviceTable(BaseTable): class Meta(BaseTable.Meta): model = Device fields = ( - 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', - 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', + 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', ) default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip', + 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', + 'primary_ip', ) @@ -230,6 +245,11 @@ class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True ) + cable_color = ColorColumn( + accessor='cable.color', + orderable=False, + verbose_name='Cable Color' + ) cable_peer = tables.TemplateColumn( accessor='_cable_peer', template_code=CABLETERMINATION, @@ -241,7 +261,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): connection = tables.TemplateColumn( - accessor='_path.destination', + accessor='_path.last_node', template_code=CABLETERMINATION, verbose_name='Connection', orderable=False @@ -249,6 +269,12 @@ class PathEndpointTable(CableTerminationTable): class ConsolePortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleport_list' ) @@ -256,8 +282,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') @@ -276,16 +302,22 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleserverports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleserverport_list' ) @@ -293,8 +325,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') @@ -314,16 +346,22 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_powerports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:powerport_list' ) @@ -332,7 +370,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_peer', 'connection', 'tags', + 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -353,18 +391,24 @@ class DevicePowerPortTable(PowerPortTable): model = PowerPort fields = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerOutletTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_poweroutlets', + 'args': [Accessor('device_id')], + } + ) power_port = tables.Column( linkify=True ) @@ -376,7 +420,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', + 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -396,13 +440,13 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -422,6 +466,12 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_interfaces', + 'args': [Accessor('device_id')], + } + ) mgmt_only = BooleanColumn() tags = TagColumn( url_name='dcim:interface_list' @@ -431,7 +481,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -462,7 +512,7 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( @@ -470,12 +520,18 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '', + 'class': get_cabletermination_row_class, 'data-name': lambda record: record.name, } class FrontPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_frontports', + 'args': [Accessor('device_id')], + } + ) rear_port_position = tables.Column( verbose_name='Position' ) @@ -490,7 +546,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', - 'cable', 'cable_peer', 'tags', + 'cable', 'cable_color', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') @@ -511,18 +567,24 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_peer', 'tags', 'actions', + 'cable_color', 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class RearPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_rearports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:rearport_list' ) @@ -531,7 +593,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', - 'cable_peer', 'tags', + 'cable_color', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -551,18 +613,24 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_peer', 'tags', - 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class DeviceBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_devicebays', + 'args': [Accessor('device_id')], + } + ) status = tables.TemplateColumn( template_code=DEVICEBAY_STATUS ) @@ -602,6 +670,12 @@ class DeviceDeviceBayTable(DeviceBayTable): class InventoryItemTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_inventory', + 'args': [Accessor('device_id')], + } + ) manufacturer = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c5b8bb70d..0a445171d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -26,7 +26,9 @@ __all__ = ( class ManufacturerTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) devicetype_count = tables.Column( verbose_name='Device Types' ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ce69b0ede..1c4d6e921 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,10 +1,8 @@ import django_tables2 as tables -from django_tables2.utils import Accessor from dcim.models import PowerFeed, PowerPanel from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn from .devices import CableTerminationTable -from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION __all__ = ( 'PowerFeedTable', @@ -18,7 +16,9 @@ __all__ = ( class PowerPanelTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) site = tables.Column( linkify=True ) @@ -45,7 +45,9 @@ class PowerPanelTable(BaseTable): # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) power_panel = tables.Column( linkify=True ) @@ -68,7 +70,8 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index eb4b28710..3a63eef1e 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,47 +1,21 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Rack, Location, RackReservation, RackRole +from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn, - TagColumn, ToggleColumn, UtilizationColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) -from .template_code import LOCATION_ELEVATIONS __all__ = ( 'RackTable', 'RackDetailTable', - 'LocationTable', 'RackReservationTable', 'RackRoleTable', ) -# -# Rack groups -# - -class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn() - site = tables.Column( - linkify=True - ) - rack_count = tables.Column( - verbose_name='Racks' - ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS - ) - - class Meta(BaseTable.Meta): - model = Location - fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') - - # # Rack roles # @@ -69,7 +43,7 @@ class RackTable(BaseTable): order_by=('_name',), linkify=True ) - group = tables.Column( + location = tables.Column( linkify=True ) site = tables.Column( @@ -86,10 +60,10 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', ) - default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') + default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height') class RackDetailTable(RackTable): @@ -111,11 +85,11 @@ class RackDetailTable(RackTable): class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', ) default_columns = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index e22037255..b7d46eba5 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,10 +1,14 @@ import django_tables2 as tables -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) +from .template_code import LOCATION_ELEVATIONS __all__ = ( + 'LocationTable', 'RegionTable', 'SiteTable', 'SiteGroupTable', @@ -17,8 +21,12 @@ __all__ = ( class RegionTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() - site_count = tables.Column( + name = MPTTColumn( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'region_id': 'pk'}, verbose_name='Sites' ) actions = ButtonsColumn(Region) @@ -35,8 +43,12 @@ class RegionTable(BaseTable): class SiteGroupTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() - site_count = tables.Column( + name = MPTTColumn( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'group_id': 'pk'}, verbose_name='Sites' ) actions = ButtonsColumn(SiteGroup) @@ -53,8 +65,8 @@ class SiteGroupTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) + name = tables.Column( + linkify=True ) status = ChoiceFieldColumn() region = tables.Column( @@ -76,3 +88,32 @@ class SiteTable(BaseTable): 'contact_email', 'tags', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + + +# +# Locations +# + +class LocationTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + site = tables.Column( + linkify=True + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + device_count = tables.Column( + verbose_name='Devices' + ) + actions = ButtonsColumn( + model=Location, + prepend_template=LOCATION_ELEVATIONS + ) + + class Meta(BaseTable.Meta): + model = Location + fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index e12f6ef87..fa22dd5c0 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,10 +1,12 @@ CABLETERMINATION = """ {% if value %} + {% if value.parent_object %} {{ value.parent_object }} - {{ value }} + {% endif %} + {{ value }} {% else %} - — + — {% endif %} """ @@ -101,6 +103,8 @@ CONSOLEPORT_BUTTONS = """
  • Rear Port
  • +{% else %} + {% endif %} """ @@ -126,6 +130,8 @@ CONSOLESERVERPORT_BUTTONS = """
  • Rear Port
  • +{% else %} + {% endif %} """ @@ -150,6 +156,8 @@ POWERPORT_BUTTONS = """
  • Power Feed
  • +{% else %} + {% endif %} """ @@ -165,9 +173,13 @@ POWEROUTLET_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - + {% if not record.mark_connected %} + + + + {% else %} + + {% endif %} {% endif %} """ @@ -188,6 +200,7 @@ INTERFACE_BUTTONS = """ {% elif record.is_connectable and perms.dcim.add_cable %} + {% if not record.mark_connected %} - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -242,17 +262,21 @@ REARPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 594a4d4f5..24130c649 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -86,6 +86,35 @@ class RegionTest(APIViewTestCases.APIViewTestCase): Region.objects.create(name='Region 3', slug='region-3') +class SiteGroupTest(APIViewTestCases.APIViewTestCase): + model = SiteGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] + create_data = [ + { + 'name': 'Site Group 4', + 'slug': 'site-group-4', + }, + { + 'name': 'Site Group 5', + 'slug': 'site-group-5', + }, + { + 'name': 'Site Group 6', + 'slug': 'site-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') + SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') + + class SiteTest(APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['display', 'id', 'name', 'slug', 'url'] diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 37d7014f1..c0fc89f83 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -229,40 +229,6 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_interface_to_circuittermination(self): - """ - [IF1] --C1-- [CT1A] - """ - interface1 = Interface.objects.create(device=self.device, name='Interface 1') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - - # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) - cable1.save() - path1 = self.assertPathExists( - origin=interface1, - destination=circuittermination1, - path=(cable1,), - is_active=True - ) - path2 = self.assertPathExists( - origin=circuittermination1, - destination=interface1, - path=(cable1,), - is_active=True - ) - self.assertEqual(CablePath.objects.count(), 2) - interface1.refresh_from_db() - circuittermination1.refresh_from_db() - self.assertPathIsSet(interface1, path1) - self.assertPathIsSet(circuittermination1, path2) - - # Delete cable 1 - cable1.delete() - - # Check that all CablePaths have been deleted - self.assertEqual(CablePath.objects.count(), 0) - def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] @@ -820,6 +786,294 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + def test_208_circuittermination(self): + """ + [IF1] --C1-- [CT1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + + # Check for incomplete path + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_209_circuit_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + + # Check for partial path from interface1 + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + + # Create CT2 + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Check for partial path to site + self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + + # Create cable 2 + cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) + cable2.save() + + # Check for complete path in each direction + self.assertPathExists( + origin=interface1, + destination=interface2, + path=(cable1, circuittermination1, circuittermination2, cable2), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=(cable2, circuittermination2, circuittermination1, cable1), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsNotSet(interface2) + + def test_210_circuit_to_site(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [Site2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + site2 = Site.objects.create(name='Site 2', slug='site-2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=site2, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_211_circuit_to_providernetwork(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [PN1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=providernetwork, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_212_multiple_paths_via_circuit(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1.save() + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2.save() + cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1 + cable3.save() + cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2 + cable4.save() + cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1 + cable5.save() + cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2 + cable6.save() + self.assertPathExists( + origin=interface1, + destination=interface3, + path=( + cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_1, cable5 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface4, + path=( + cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_2, cable6 + ), + is_active=True + ) + self.assertPathExists( + origin=interface3, + destination=interface1, + path=( + cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_1, cable1 + ), + is_active=True + ) + self.assertPathExists( + origin=interface4, + destination=interface2, + path=( + cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_2, cable2 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cables 3-4 + cable3.delete() + cable4.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_213_multiple_circuits_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') + circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + cable2 = Cable(termination_a=circuittermination2, termination_b=circuittermination3) + cable2.save() + cable3 = Cable(termination_a=circuittermination4, termination_b=interface2) + cable3.save() + + # Check for paths + self.assertPathExists( + origin=interface1, + destination=interface2, + path=( + cable1, circuittermination1, circuittermination2, cable2, circuittermination3, circuittermination4, + cable3 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=( + cable3, circuittermination4, circuittermination3, cable2, circuittermination2, circuittermination1, + cable1 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + path2 = self.assertPathExists( + origin=interface2, + destination=self.site, + path=(cable3, circuittermination4, circuittermination3), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 847618f41..a68733f86 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1601,9 +1601,9 @@ class ConsolePortTestCase(TestCase): ConsoleServerPort.objects.bulk_create(console_server_ports) console_ports = ( - ConsolePort(device=devices[0], name='Console Port 1', description='First'), - ConsolePort(device=devices[1], name='Console Port 2', description='Second'), - ConsolePort(device=devices[2], name='Console Port 3', description='Third'), + ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'), + ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'), + ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'), ) ConsolePort.objects.bulk_create(console_ports) @@ -1620,6 +1620,10 @@ class ConsolePortTestCase(TestCase): params = {'name': ['Console Port 1', 'Console Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1713,9 +1717,9 @@ class ConsoleServerPortTestCase(TestCase): ConsolePort.objects.bulk_create(console_ports) console_server_ports = ( - ConsoleServerPort(device=devices[0], name='Console Server Port 1', description='First'), - ConsoleServerPort(device=devices[1], name='Console Server Port 2', description='Second'), - ConsoleServerPort(device=devices[2], name='Console Server Port 3', description='Third'), + ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'), + ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'), + ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'), ) ConsoleServerPort.objects.bulk_create(console_server_ports) @@ -1732,6 +1736,10 @@ class ConsoleServerPortTestCase(TestCase): params = {'name': ['Console Server Port 1', 'Console Server Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1825,9 +1833,9 @@ class PowerPortTestCase(TestCase): PowerOutlet.objects.bulk_create(power_outlets) power_ports = ( - PowerPort(device=devices[0], name='Power Port 1', maximum_draw=100, allocated_draw=50, description='First'), - PowerPort(device=devices[1], name='Power Port 2', maximum_draw=200, allocated_draw=100, description='Second'), - PowerPort(device=devices[2], name='Power Port 3', maximum_draw=300, allocated_draw=150, description='Third'), + PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'), + PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'), + PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'), ) PowerPort.objects.bulk_create(power_ports) @@ -1844,6 +1852,10 @@ class PowerPortTestCase(TestCase): params = {'name': ['Power Port 1', 'Power Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1945,9 +1957,9 @@ class PowerOutletTestCase(TestCase): PowerPort.objects.bulk_create(power_ports) power_outlets = ( - PowerOutlet(device=devices[0], name='Power Outlet 1', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), - PowerOutlet(device=devices[1], name='Power Outlet 2', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), - PowerOutlet(device=devices[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), + PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), + PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), + PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -1964,6 +1976,10 @@ class PowerOutletTestCase(TestCase): params = {'name': ['Power Outlet 1', 'Power Outlet 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2056,12 +2072,12 @@ class InterfaceTestCase(TestCase): Device.objects.bulk_create(devices) interfaces = ( - Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), - Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), - Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), - Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 6', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), ) Interface.objects.bulk_create(interfaces) @@ -2078,6 +2094,10 @@ class InterfaceTestCase(TestCase): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) @@ -2237,12 +2257,12 @@ class FrontPortTestCase(TestCase): RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), - FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), - FrontPort(device=devices[2], name='Front Port 3', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), - FrontPort(device=devices[3], name='Front Port 4', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 5', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 6', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), + FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), + FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), + FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), + FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), ) FrontPort.objects.bulk_create(front_ports) @@ -2259,6 +2279,10 @@ class FrontPortTestCase(TestCase): params = {'name': ['Front Port 1', 'Front Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): # TODO: Test for multiple values params = {'type': PortTypeChoices.TYPE_8P8C} @@ -2345,12 +2369,12 @@ class RearPortTestCase(TestCase): Device.objects.bulk_create(devices) rear_ports = ( - RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), - RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), - RearPort(device=devices[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), - RearPort(device=devices[3], name='Rear Port 4', type=PortTypeChoices.TYPE_FC, positions=4), - RearPort(device=devices[3], name='Rear Port 5', type=PortTypeChoices.TYPE_FC, positions=5), - RearPort(device=devices[3], name='Rear Port 6', type=PortTypeChoices.TYPE_FC, positions=6), + RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), + RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), + RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), + RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4), + RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5), + RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6), ) RearPort.objects.bulk_create(rear_ports) @@ -2367,6 +2391,10 @@ class RearPortTestCase(TestCase): params = {'name': ['Rear Port 1', 'Rear Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): # TODO: Test for multiple values params = {'type': PortTypeChoices.TYPE_8P8C} @@ -2456,9 +2484,9 @@ class DeviceBayTestCase(TestCase): Device.objects.bulk_create(devices) device_bays = ( - DeviceBay(device=devices[0], name='Device Bay 1', description='First'), - DeviceBay(device=devices[1], name='Device Bay 2', description='Second'), - DeviceBay(device=devices[2], name='Device Bay 3', description='Third'), + DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), + DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), + DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), ) DeviceBay.objects.bulk_create(device_bays) @@ -2470,6 +2498,10 @@ class DeviceBayTestCase(TestCase): params = {'name': ['Device Bay 1', 'Device Bay 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2551,9 +2583,9 @@ class InventoryItemTestCase(TestCase): Device.objects.bulk_create(devices) inventory_items = ( - InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), - InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), - InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), + InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), + InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), + InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), ) for i in inventory_items: i.save() @@ -2574,6 +2606,10 @@ class InventoryItemTestCase(TestCase): params = {'name': ['Inventory Item 1', 'Inventory Item 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_part_id(self): params = {'part_id': ['1001', '1002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 815d86758..ae280365e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -479,10 +479,13 @@ class CableTestCase(TestCase): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') + self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ @@ -552,6 +555,14 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_cable_cannot_terminate_to_a_provider_network_circuittermination(self): + """ + Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork + """ + cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + with self.assertRaises(ValidationError): + cable.clean() + def test_rearport_connections(self): """ Test various combinations of RearPort connections. diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8fde267d9..daba2a639 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,20 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN -from utilities.testing import ViewTestCases - - -def create_test_device(name): - """ - Convenience method for creating a Device (e.g. for component testing). - """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') - manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') - devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) - devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) - - return device +from utilities.testing import ViewTestCases, create_test_device class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -1246,6 +1233,18 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + consoleport = ConsolePort.objects.first() + consoleserverport = ConsoleServerPort.objects.create( + device=consoleport.device, + name='Console Server Port 1' + ) + Cable(termination_a=consoleport, termination_b=consoleserverport).save() + + response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk})) + self.assertHttpStatus(response, 200) + class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort @@ -1290,6 +1289,18 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + consoleserverport = ConsoleServerPort.objects.first() + consoleport = ConsolePort.objects.create( + device=consoleserverport.device, + name='Console Port 1' + ) + Cable(termination_a=consoleserverport, termination_b=consoleport).save() + + response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk})) + self.assertHttpStatus(response, 200) + class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort @@ -1340,6 +1351,18 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + powerport = PowerPort.objects.first() + poweroutlet = PowerOutlet.objects.create( + device=powerport.device, + name='Power Outlet 1' + ) + Cable(termination_a=powerport, termination_b=poweroutlet).save() + + response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk})) + self.assertHttpStatus(response, 200) + class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet @@ -1396,6 +1419,15 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + poweroutlet = PowerOutlet.objects.first() + powerport = PowerPort.objects.first() + Cable(termination_a=poweroutlet, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk})) + self.assertHttpStatus(response, 200) + class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface @@ -1475,6 +1507,14 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Interface 6,1000base-t", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + interface1, interface2 = Interface.objects.all()[:2] + Cable(termination_a=interface1, termination_b=interface2).save() + + response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) + self.assertHttpStatus(response, 200) + class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort @@ -1534,6 +1574,18 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + frontport = FrontPort.objects.first() + interface = Interface.objects.create( + device=frontport.device, + name='Interface 1' + ) + Cable(termination_a=frontport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk})) + self.assertHttpStatus(response, 200) + class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort @@ -1580,6 +1632,18 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + rearport = RearPort.objects.first() + interface = Interface.objects.create( + device=rearport.device, + name='Interface 1' + ) + Cable(termination_a=rearport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk})) + self.assertHttpStatus(response, 200) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay @@ -1938,3 +2002,26 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'max_utilization': 50, 'comments': 'New comments', } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_trace(self): + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role', slug='device-role-1' + ) + device = Device.objects.create( + site=Site.objects.first(), device_type=device_type, device_role=device_role + ) + + powerfeed = PowerFeed.objects.first() + powerport = PowerPort.objects.create( + device=device, + name='Power Port 1' + ) + Cable(termination_a=powerfeed, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) + self.assertHttpStatus(response, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e7c29ae9f..11ffd4458 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,6 +2,7 @@ from django.urls import path from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView +from utilities.views import SlugRedirectView from . import views from .models import * @@ -14,6 +15,7 @@ urlpatterns = [ path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), @@ -24,6 +26,7 @@ urlpatterns = [ path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), + path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), @@ -35,6 +38,7 @@ urlpatterns = [ path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites//', views.SiteView.as_view(), name='site'), + path('sites//', SlugRedirectView.as_view(), kwargs={'model': Site}), path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), @@ -47,9 +51,11 @@ urlpatterns = [ path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), + path('locations//', views.LocationView.as_view(), name='location'), path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -57,6 +63,7 @@ urlpatterns = [ path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), @@ -93,6 +100,7 @@ urlpatterns = [ path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), @@ -179,6 +187,7 @@ urlpatterns = [ path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), @@ -189,6 +198,7 @@ urlpatterns = [ path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path('platforms//', views.PlatformView.as_view(), name='platform'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), @@ -408,6 +418,7 @@ urlpatterns = [ path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4ca9b8498..53e842ff9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,6 +20,7 @@ from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -111,6 +112,34 @@ class RegionListView(generic.ObjectListView): table = tables.RegionTable +class RegionView(generic.ObjectView): + queryset = Region.objects.all() + + def get_extra_context(self, request, instance): + child_regions = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_regions_table = tables.RegionTable(child_regions) + + sites = Site.objects.restrict(request.user, 'view').filter( + region=instance + ) + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('region') + paginate_table(sites_table, request) + + return { + 'child_regions_table': child_regions_table, + 'sites_table': sites_table, + } + + class RegionEditView(generic.ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm @@ -168,6 +197,34 @@ class SiteGroupListView(generic.ObjectListView): table = tables.SiteGroupTable +class SiteGroupView(generic.ObjectView): + queryset = SiteGroup.objects.all() + + def get_extra_context(self, request, instance): + child_groups = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_groups_table = tables.SiteGroupTable(child_groups) + + sites = Site.objects.restrict(request.user, 'view').filter( + group=instance + ) + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('group') + paginate_table(sites_table, request) + + return { + 'child_groups_table': child_groups_table, + 'sites_table': sites_table, + } + + class SiteGroupEditView(generic.ObjectEditView): queryset = SiteGroup.objects.all() model_form = forms.SiteGroupForm @@ -237,6 +294,13 @@ class SiteView(generic.ObjectView): 'location', 'rack_count', cumulative=True + ) + locations = Location.objects.add_related_count( + locations, + Device, + 'location', + 'device_count', + cumulative=True ).restrict(request.user, 'view').filter(site=instance) return { @@ -274,12 +338,18 @@ class SiteBulkDeleteView(generic.BulkDeleteView): # -# Rack groups +# Locations # class LocationListView(generic.ObjectListView): queryset = Location.objects.add_related_count( - Location.objects.all(), + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, 'location', 'rack_count', @@ -290,6 +360,23 @@ class LocationListView(generic.ObjectListView): table = tables.LocationTable +class LocationView(generic.ObjectView): + queryset = Location.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + location=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('location') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class LocationEditView(generic.ObjectEditView): queryset = Location.objects.all() model_form = forms.LocationForm @@ -341,6 +428,23 @@ class RackRoleListView(generic.ObjectListView): table = tables.RackRoleTable +class RackRoleView(generic.ObjectView): + queryset = RackRole.objects.all() + + def get_extra_context(self, request, instance): + racks = Rack.objects.restrict(request.user, 'view').filter( + role=instance + ) + + racks_table = tables.RackTable(racks) + racks_table.columns.hide('role') + paginate_table(racks_table, request) + + return { + 'racks_table': racks_table, + } + + class RackRoleEditView(generic.ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm @@ -433,10 +537,11 @@ class RackView(generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): - # Get 0U and child devices located within the rack + # Get 0U devices located within the rack nonracked_devices = Device.objects.filter( rack=instance, - position__isnull=True + position__isnull=True, + parent_bay__isnull=True ).prefetch_related('device_type__manufacturer') peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) @@ -567,6 +672,23 @@ class ManufacturerListView(generic.ObjectListView): table = tables.ManufacturerTable +class ManufacturerView(generic.ObjectView): + queryset = Manufacturer.objects.all() + + def get_extra_context(self, request, instance): + devicetypes = DeviceType.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ) + + devicetypes_table = tables.DeviceTypeTable(devicetypes) + devicetypes_table.columns.hide('manufacturer') + paginate_table(devicetypes_table, request) + + return { + 'devicetypes_table': devicetypes_table, + } + + class ManufacturerEditView(generic.ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm @@ -1017,6 +1139,23 @@ class DeviceRoleListView(generic.ObjectListView): table = tables.DeviceRoleTable +class DeviceRoleView(generic.ObjectView): + queryset = DeviceRole.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + device_role=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('device_role') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class DeviceRoleEditView(generic.ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm @@ -1056,6 +1195,23 @@ class PlatformListView(generic.ObjectListView): table = tables.PlatformTable +class PlatformView(generic.ObjectView): + queryset = Platform.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + platform=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('platform') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class PlatformEditView(generic.ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm @@ -1148,6 +1304,7 @@ class DeviceConsolePortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): consoleport_table.columns.show('pk') + paginate_table(consoleport_table, request) return { 'consoleport_table': consoleport_table, @@ -1173,6 +1330,7 @@ class DeviceConsoleServerPortsView(generic.ObjectView): if request.user.has_perm('dcim.change_consoleserverport') or \ request.user.has_perm('dcim.delete_consoleserverport'): consoleserverport_table.columns.show('pk') + paginate_table(consoleserverport_table, request) return { 'consoleserverport_table': consoleserverport_table, @@ -1195,6 +1353,7 @@ class DevicePowerPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): powerport_table.columns.show('pk') + paginate_table(powerport_table, request) return { 'powerport_table': powerport_table, @@ -1217,6 +1376,7 @@ class DevicePowerOutletsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'): poweroutlet_table.columns.show('pk') + paginate_table(poweroutlet_table, request) return { 'poweroutlet_table': poweroutlet_table, @@ -1241,6 +1401,7 @@ class DeviceInterfacesView(generic.ObjectView): ) if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'): interface_table.columns.show('pk') + paginate_table(interface_table, request) return { 'interface_table': interface_table, @@ -1263,6 +1424,7 @@ class DeviceFrontPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'): frontport_table.columns.show('pk') + paginate_table(frontport_table, request) return { 'frontport_table': frontport_table, @@ -1283,6 +1445,7 @@ class DeviceRearPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): rearport_table.columns.show('pk') + paginate_table(rearport_table, request) return { 'rearport_table': rearport_table, @@ -1305,6 +1468,7 @@ class DeviceDeviceBaysView(generic.ObjectView): ) if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'): devicebay_table.columns.show('pk') + paginate_table(devicebay_table, request) return { 'devicebay_table': devicebay_table, @@ -1327,6 +1491,7 @@ class DeviceInventoryView(generic.ObjectView): ) if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'): inventoryitem_table.columns.show('pk') + paginate_table(inventoryitem_table, request) return { 'inventoryitem_table': inventoryitem_table, @@ -1450,11 +1615,6 @@ class ConsolePortListView(generic.ObjectListView): class ConsolePortView(generic.ObjectView): queryset = ConsolePort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleports' - } - class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() @@ -1515,11 +1675,6 @@ class ConsoleServerPortListView(generic.ObjectListView): class ConsoleServerPortView(generic.ObjectView): queryset = ConsoleServerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleserverports' - } - class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() @@ -1580,11 +1735,6 @@ class PowerPortListView(generic.ObjectListView): class PowerPortView(generic.ObjectView): queryset = PowerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_powerports' - } - class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() @@ -1645,11 +1795,6 @@ class PowerOutletListView(generic.ObjectListView): class PowerOutletView(generic.ObjectView): queryset = PowerOutlet.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_poweroutlets' - } - class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() @@ -1717,6 +1862,14 @@ class InterfaceView(generic.ObjectView): orderable=False ) + # Get child interfaces + child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) + child_interfaces_tables = tables.InterfaceTable( + child_interfaces, + orderable=False + ) + child_interfaces_tables.columns.hide('device') + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -1733,8 +1886,8 @@ class InterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, - 'breadcrumb_url': 'dcim:device_interfaces' } @@ -1797,11 +1950,6 @@ class FrontPortListView(generic.ObjectListView): class FrontPortView(generic.ObjectView): queryset = FrontPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_frontports' - } - class FrontPortCreateView(generic.ComponentCreateView): queryset = FrontPort.objects.all() @@ -1862,11 +2010,6 @@ class RearPortListView(generic.ObjectListView): class RearPortView(generic.ObjectView): queryset = RearPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_rearports' - } - class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() @@ -1927,11 +2070,6 @@ class DeviceBayListView(generic.ObjectListView): class DeviceBayView(generic.ObjectView): queryset = DeviceBay.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_devicebays' - } - class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() @@ -2053,11 +2191,6 @@ class InventoryItemListView(generic.ObjectListView): class InventoryItemView(generic.ObjectView): queryset = InventoryItem.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_inventory' - } - class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() @@ -2250,10 +2383,14 @@ class PathTraceView(generic.ObjectView): else: path = related_paths.first() + # Get the total length of the cable and whether the length is definitive (fully defined) + total_length, is_definitive = path.get_total_length() if path else (None, False) + return { 'path': path, 'related_paths': related_paths, - 'total_length': path.get_total_length() if path else None, + 'total_length': total_length, + 'is_definitive': is_definitive } @@ -2699,6 +2836,8 @@ class PowerPanelView(generic.ObjectView): data=power_feeds, orderable=False ) + if request.user.has_perm('dcim.delete_cable'): + powerfeed_table.columns.show('pk') powerfeed_table.exclude = ['power_panel'] return { @@ -2775,6 +2914,10 @@ class PowerFeedBulkEditView(generic.BulkEditView): form = forms.PowerFeedBulkEditForm +class PowerFeedBulkDisconnectView(BulkDisconnectView): + queryset = PowerFeed.objects.all() + + class PowerFeedBulkDeleteView(generic.BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 4e11307fc..0ceb1cc5b 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,16 +1,12 @@ from django import forms from django.contrib import admin +from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe -from utilities.forms import LaxURLField +from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField +from utilities.utils import content_type_name from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook - - -def order_content_types(field): - """ - Order the list of available ContentTypes by application - """ - queryset = field.queryset.order_by('app_label', 'model') - field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] +from .utils import FeatureQuery # @@ -18,6 +14,10 @@ def order_content_types(field): # class WebhookForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks') + ) payload_url = LaxURLField( label='URL' ) @@ -26,12 +26,6 @@ class WebhookForm(forms.ModelForm): model = Webhook exclude = () - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if 'content_types' in self.fields: - order_content_types(self.fields['content_types']) - @admin.register(Webhook) class WebhookAdmin(admin.ModelAdmin): @@ -70,6 +64,10 @@ class WebhookAdmin(admin.ModelAdmin): # class CustomFieldForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) class Meta: model = CustomField @@ -84,11 +82,6 @@ class CustomFieldForm(forms.ModelForm): ) } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - order_content_types(self.fields['content_types']) - @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): @@ -119,7 +112,8 @@ class CustomFieldAdmin(admin.ModelAdmin): ) def models(self, obj): - return ', '.join([ct.name for ct in obj.content_types.all()]) + ct_names = [content_type_name(ct) for ct in obj.content_types.all()] + return mark_safe('
    '.join(ct_names)) # @@ -127,6 +121,10 @@ class CustomFieldAdmin(admin.ModelAdmin): # class CustomLinkForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = CustomLink @@ -143,13 +141,6 @@ class CustomLinkForm(forms.ModelForm): 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(CustomLink) class CustomLinkAdmin(admin.ModelAdmin): @@ -176,24 +167,21 @@ class CustomLinkAdmin(admin.ModelAdmin): # class ExportTemplateForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = ExportTemplate exclude = [] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): fieldsets = ( ('Export Template', { - 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension') + 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment') }), ('Content', { 'fields': ('template_code',), @@ -201,7 +189,7 @@ class ExportTemplateAdmin(admin.ModelAdmin): }) ) list_display = [ - 'name', 'content_type', 'description', 'mime_type', 'file_extension', + 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', ] list_filter = [ 'content_type', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d1eea15ee..66627bfbc 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,10 +4,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, + NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -116,7 +116,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', + 'file_extension', 'as_attachment', ] @@ -251,6 +251,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=NestedDeviceTypeSerializer, + required=False, + many=True + ) roles = SerializedPKRelatedField( queryset=DeviceRole.objects.all(), serializer=NestedDeviceRoleSerializer, @@ -298,8 +304,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', - 'last_updated', + 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 3ac25eec4..4b5c42eeb 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.forms import DateField, IntegerField, NullBooleanField -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet, ContentTypeFilter from virtualization.models import Cluster, ClusterGroup @@ -90,6 +90,7 @@ class CustomFieldModelFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(django_filters.FilterSet): + content_types = ContentTypeFilter() class Meta: model = CustomField @@ -206,6 +207,11 @@ class ConfigContextFilterSet(BaseFilterSet): to_field_name='slug', label='Site (slug)', ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device_types', + queryset=DeviceType.objects.all(), + label='Device type', + ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 7720905d7..e0fe8817a 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, @@ -218,6 +218,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + device_types = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) roles = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False @@ -253,8 +257,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', + 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) @@ -306,6 +310,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label=_('Sites') ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device types') + ) role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, @@ -376,6 +385,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): # class JournalEntryForm(BootstrapMixin, forms.ModelForm): + comments = CommentField() kind = forms.ChoiceField( choices=add_blank_choice(JournalEntryKindChoices), diff --git a/netbox/extras/migrations/0056_sitegroup.py b/netbox/extras/migrations/0056_extend_configcontext.py similarity index 65% rename from netbox/extras/migrations/0056_sitegroup.py rename to netbox/extras/migrations/0056_extend_configcontext.py index b81cdb8a1..9c7e2d700 100644 --- a/netbox/extras/migrations/0056_sitegroup.py +++ b/netbox/extras/migrations/0056_extend_configcontext.py @@ -14,4 +14,9 @@ class Migration(migrations.Migration): name='site_groups', field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'), ), + migrations.AddField( + model_name='configcontext', + name='device_types', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'), + ), ] diff --git a/netbox/extras/migrations/0057_customlink_rename_fields.py b/netbox/extras/migrations/0057_customlink_rename_fields.py index 4ed5c7bc7..6aba35d9f 100644 --- a/netbox/extras/migrations/0057_customlink_rename_fields.py +++ b/netbox/extras/migrations/0057_customlink_rename_fields.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('extras', '0056_sitegroup'), + ('extras', '0056_extend_configcontext'), ] operations = [ diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py index 14be2a50d..22abf965c 100644 --- a/netbox/extras/migrations/0058_journalentry.py +++ b/netbox/extras/migrations/0058_journalentry.py @@ -18,6 +18,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(primary_key=True, serialize=False)), ('assigned_object_id', models.PositiveIntegerField()), ('created', models.DateTimeField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('kind', models.CharField(default='info', max_length=30)), ('comments', models.TextField()), ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), diff --git a/netbox/extras/migrations/0059_exporttemplate_as_attachment.py b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py new file mode 100644 index 000000000..6e6ae0413 --- /dev/null +++ b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0058_journalentry'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='as_attachment', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index b7da6852c..8c142de8b 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -56,6 +56,11 @@ class ConfigContext(ChangeLoggedModel): related_name='+', blank=True ) + device_types = models.ManyToManyField( + to='dcim.DeviceType', + related_name='+', + blank=True + ) roles = models.ManyToManyField( to='dcim.DeviceRole', related_name='+', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 61d06d264..41bc345e2 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -7,13 +7,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse +from django.urls import reverse from django.utils import timezone +from django.utils.formats import date_format, time_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.utils import extras_features, FeatureQuery, image_upload -from netbox.models import BigIDModel +from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -151,7 +153,7 @@ class Webhook(BigIDModel): ret = {} data = render_jinja2(self.additional_headers, context) for line in data.splitlines(): - header, value = line.split(':') + header, value = line.split(':', 1) ret[header.strip()] = value.strip() return ret @@ -251,6 +253,10 @@ class ExportTemplate(BigIDModel): blank=True, help_text='Extension to append to the rendered filename' ) + as_attachment = models.BooleanField( + default=True, + help_text="Download file as attachment" + ) objects = RestrictedQuerySet.as_manager() @@ -261,7 +267,15 @@ class ExportTemplate(BigIDModel): ] def __str__(self): - return '{}: {}'.format(self.content_type, self.name) + return f"{self.content_type}: {self.name}" + + def clean(self): + super().clean() + + if self.name.lower() == 'table': + raise ValidationError({ + 'name': f'"{self.name}" is a reserved name. Please choose a different name.' + }) def render(self, queryset): """ @@ -290,7 +304,9 @@ class ExportTemplate(BigIDModel): queryset.model._meta.verbose_name_plural, '.{}'.format(self.file_extension) if self.file_extension else '' ) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + + if self.as_attachment: + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response @@ -375,7 +391,7 @@ class ImageAttachment(BigIDModel): # Journal entries # -class JournalEntry(BigIDModel): +class JournalEntry(ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -413,7 +429,10 @@ class JournalEntry(BigIDModel): verbose_name_plural = 'journal entries' def __str__(self): - return f"{self.created} - {self.get_kind_display()}" + return f"{date_format(self.created)} - {time_format(self.created)} ({self.get_kind_display()})" + + def get_absolute_url(self): + return reverse('extras:journalentry', args=[self.pk]) def get_kind_class(self): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 4c61f7f88..6268751b2 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,4 +1,5 @@ from django.db import models +from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase @@ -30,6 +31,9 @@ class Tag(ChangeLoggedModel, TagBase): class Meta: ordering = ['name'] + def get_absolute_url(self): + return reverse('extras:tag', args=[self.pk]) + def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 3710bec46..be5ae6416 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,7 +19,10 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Virtualization cluster for VirtualMachine + # Device type assignment is relevant only for Devices + device_type = getattr(obj, 'device_type', None) + + # Cluster assignment is relevant only for VirtualMachines cluster = getattr(obj, 'cluster', None) cluster_group = getattr(cluster, 'group', None) @@ -28,14 +31,17 @@ class ConfigContextQuerySet(RestrictedQuerySet): # Match against the directly assigned region as well as any parent regions. region = getattr(obj.site, 'region', None) - if region: - regions = region.get_ancestors(include_self=True) - else: - regions = [] + regions = region.get_ancestors(include_self=True) if region else [] + + # Match against the directly assigned site group as well as any parent site groups. + sitegroup = getattr(obj.site, 'group', None) + sitegroups = sitegroup.get_ancestors(include_self=True) if sitegroup else [] queryset = self.filter( Q(regions__in=regions) | Q(regions=None), + Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), Q(cluster_groups=cluster_group) | Q(cluster_groups=None), @@ -108,14 +114,17 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND) region_field = 'site__region' + sitegroup_field = 'site__group' elif self.model._meta.model_name == 'virtualmachine': base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND) region_field = 'cluster__site__region' + sitegroup_field = 'cluster__site__group' base_query.add( (Q( @@ -127,4 +136,14 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): Q.AND ) + base_query.add( + (Q( + site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'), + site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'), + site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'), + site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'), + ) | Q(site_groups=None)), + Q.AND + ) + return base_query diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index ba7c725bf..1c4f4953e 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -4,6 +4,7 @@ from datetime import timedelta from cacheops.signals import cache_invalidated, cache_read from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db import DEFAULT_DB_ALIAS from django.db.models.signals import m2m_changed, pre_delete from django.utils import timezone from django_prometheus.models import model_deletes, model_inserts, model_updates @@ -22,26 +23,35 @@ def _handle_changed_object(request, sender, instance, **kwargs): """ Fires when an object is created or updated. """ - # Queue the object for processing once the request completes + m2m_changed = False + + # Determine the type of change being made if kwargs.get('created'): action = ObjectChangeActionChoices.ACTION_CREATE elif 'created' in kwargs: action = ObjectChangeActionChoices.ACTION_UPDATE elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']: # m2m_changed with objects added or removed + m2m_changed = True action = ObjectChangeActionChoices.ACTION_UPDATE else: return # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): - objectchange = instance.to_objectchange(action) - # TODO: Move this to to_objectchange() - if hasattr(instance, '_prechange_snapshot'): - objectchange.prechange_data = instance._prechange_snapshot - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() + if m2m_changed: + ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk, + request_id=request.id + ).update( + postchange_data=instance.to_objectchange(action).postchange_data + ) + else: + objectchange = instance.to_objectchange(action) + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() # Enqueue webhooks enqueue_webhooks(instance, request.user, request.id, action) @@ -55,7 +65,7 @@ def _handle_changed_object(request, sender, instance, **kwargs): # Housekeeping: 0.1% chance of clearing out expired ObjectChanges if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) - ObjectChange.objects.filter(time__lt=cutoff).delete() + ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS) def _handle_deleted_object(request, sender, instance, **kwargs): @@ -65,9 +75,6 @@ def _handle_deleted_object(request, sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) - # TODO: Move this to to_objectchange() - if hasattr(instance, '_prechange_snapshot'): - objectchange.prechange_data = instance._prechange_snapshot objectchange.user = request.user objectchange.request_id = request.id objectchange.save() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index e034b915a..dd7b45c6f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,17 +1,11 @@ import django_tables2 as tables from django.conf import settings -from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn, +) from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem -TAGGED_ITEM = """ -{% if value.get_absolute_url %} - {{ value }} -{% else %} - {{ value }} -{% endif %} -""" - CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -36,6 +30,9 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) color = ColorColumn() actions = ButtonsColumn(Tag) @@ -45,23 +42,25 @@ class TagTable(BaseTable): class TaggedItemTable(BaseTable): - content_object = tables.TemplateColumn( - template_code=TAGGED_ITEM, + content_type = ContentTypeColumn( + verbose_name='Type' + ) + content_object = tables.Column( + linkify=True, orderable=False, verbose_name='Object' ) - content_type = tables.Column( - verbose_name='Type' - ) class Meta(BaseTable.Meta): model = TaggedItem - fields = ('content_object', 'content_type') + fields = ('content_type', 'content_object') class ConfigContextTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) is_active = BooleanColumn( verbose_name='Active' ) @@ -81,7 +80,7 @@ class ObjectChangeTable(BaseTable): format=settings.SHORT_DATETIME_FORMAT ) action = ChoiceFieldColumn() - changed_object_type = tables.Column( + changed_object_type = ContentTypeColumn( verbose_name='Type' ) object_repr = tables.TemplateColumn( @@ -98,12 +97,30 @@ class ObjectChangeTable(BaseTable): fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') -class JournalEntryTable(BaseTable): - pk = ToggleColumn() +class ObjectJournalTable(BaseTable): + """ + Used for displaying a set of JournalEntries within the context of a single object. + """ created = tables.DateTimeColumn( + linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - assigned_object_type = tables.Column( + kind = ChoiceFieldColumn() + comments = tables.TemplateColumn( + template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' + ) + actions = ButtonsColumn( + model=JournalEntry + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ('created', 'created_by', 'kind', 'comments', 'actions') + + +class JournalEntryTable(ObjectJournalTable): + pk = ToggleColumn() + assigned_object_type = ContentTypeColumn( verbose_name='Object type' ) assigned_object = tables.Column( @@ -111,32 +128,9 @@ class JournalEntryTable(BaseTable): orderable=False, verbose_name='Object' ) - kind = ChoiceFieldColumn() - actions = ButtonsColumn( - model=JournalEntry, - buttons=('edit', 'delete') - ) class Meta(BaseTable.Meta): model = JournalEntry fields = ( 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' ) - - -class ObjectJournalTable(BaseTable): - """ - Used for displaying a set of JournalEntries within the context of a single object. - """ - created = tables.DateTimeColumn( - format=settings.SHORT_DATETIME_FORMAT - ) - kind = ChoiceFieldColumn() - actions = ButtonsColumn( - model=JournalEntry, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = JournalEntry - fields = ('created', 'created_by', 'kind', 'comments', 'actions') diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 5e44c83d1..91868832c 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -56,19 +56,18 @@ class ChangeLogViewTest(ModelViewTestCase): response = self.client.post(**request) self.assertHttpStatus(response, 302) + # Verify the creation of a new ObjectChange record site = Site.objects.get(name='Site 1') - # First OC is the creation; second is the tags update - oc_list = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).order_by('pk') - self.assertEqual(oc_list[0].changed_object, site) - self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].prechange_data, None) - self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) - self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) + ) + self.assertEqual(oc.changed_object, site) + self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(oc.prechange_data, None) + self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) + self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): site = Site(name='Site 1', slug='site-1') @@ -93,8 +92,8 @@ class ChangeLogViewTest(ModelViewTestCase): response = self.client.post(**request) self.assertHttpStatus(response, 302) + # Verify the creation of a new ObjectChange record site.refresh_from_db() - # Get only the most recent OC oc = ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk @@ -259,17 +258,15 @@ class ChangeLogAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) site = Site.objects.get(pk=response.data['id']) - # First OC is the creation; second is the tags update - oc_list = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).order_by('pk') - self.assertEqual(oc_list[0].changed_object, site) - self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].prechange_data, None) - self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields']) - self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) + ) + self.assertEqual(oc.changed_object, site) + self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(oc.prechange_data, None) + self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): site = Site(name='Site 1', slug='site-1') @@ -294,11 +291,10 @@ class ChangeLogAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) site = Site.objects.get(pk=response.data['id']) - # Get only the most recent OC - oc = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).first() + ) self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 5e1b0401d..bb78c4daf 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filters import * from extras.models import * @@ -379,6 +379,14 @@ class ConfigContextTestCase(TestCase): ) Site.objects.bulk_create(sites) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-3'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-4'), + ) + 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'), @@ -433,6 +441,7 @@ class ConfigContextTestCase(TestCase): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) c.cluster_groups.set([cluster_groups[i]]) @@ -475,6 +484,11 @@ class ConfigContextTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + 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) + def test_role(self): device_roles = DeviceRole.objects.all()[:2] params = {'role_id': [device_roles[0].pk, device_roles[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 645fb8618..10d4168b4 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site, Region +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -28,7 +28,8 @@ class ConfigContextTest(TestCase): self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region) + self.sitegroup = SiteGroup.objects.create(name="Site Group") + self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -160,6 +161,14 @@ class ConfigContextTest(TestCase): } ) region_context.regions.add(self.region) + sitegroup_context = ConfigContext.objects.create( + name="sitegroup", + weight=100, + data={ + "sitegroup": 1 + } + ) + sitegroup_context.site_groups.add(self.sitegroup) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -224,6 +233,14 @@ class ConfigContextTest(TestCase): } ) region_context.regions.add(self.region) + sitegroup_context = ConfigContext.objects.create( + name="sitegroup", + weight=100, + data={ + "sitegroup": 1 + } + ) + sitegroup_context.site_groups.add(self.sitegroup) platform_context = ConfigContext.objects.create( name="platform", weight=100, diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ee435307d..9ec19d215 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras import views -from extras.models import ConfigContext, Tag +from extras.models import ConfigContext, JournalEntry, Tag app_name = 'extras' @@ -13,6 +13,7 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), @@ -36,8 +37,10 @@ urlpatterns = [ path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), + path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), + path('journal-entries//changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 976c13760..4cda84d99 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,18 +1,16 @@ -from django import template from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Count, Q from django.http import Http404, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View from django_rq.queues import get_connection -from django_tables2 import RequestConfig from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import copy_safe_request, count_related, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filters, forms, tables @@ -35,6 +33,31 @@ class TagListView(generic.ObjectListView): table = tables.TagTable +class TagView(generic.ObjectView): + queryset = Tag.objects.all() + + def get_extra_context(self, request, instance): + tagged_items = TaggedItem.objects.filter(tag=instance) + taggeditem_table = tables.TaggedItemTable( + data=tagged_items, + orderable=False + ) + paginate_table(taggeditem_table, request) + + object_types = [ + { + 'content_type': ContentType.objects.get(pk=ti['content_type']), + 'item_count': ti['item_count'] + } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk')) + ] + + return { + 'taggeditem_table': taggeditem_table, + 'tagged_item_count': tagged_items.count(), + 'object_types': object_types, + } + + class TagEditView(generic.ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm @@ -230,23 +253,12 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(objectchanges_table) + paginate_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. if self.base_template is None: self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" - # TODO: This can be removed once an object view has been established for every model. - try: - template.loader.get_template(self.base_template) - except template.TemplateDoesNotExist: - self.base_template = 'base.html' return render(request, 'extras/object_changelog.html', { 'object': obj, @@ -294,6 +306,10 @@ class JournalEntryListView(generic.ObjectListView): action_buttons = ('export',) +class JournalEntryView(generic.ObjectView): + queryset = JournalEntry.objects.all() + + class JournalEntryEditView(generic.ObjectEditView): queryset = JournalEntry.objects.all() model_form = forms.JournalEntryForm @@ -355,17 +371,8 @@ class ObjectJournalView(View): assigned_object_type=content_type, assigned_object_id=obj.pk ) - journalentry_table = tables.ObjectJournalTable( - data=journalentries, - orderable=False - ) - - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(journalentry_table) + journalentry_table = tables.ObjectJournalTable(journalentries) + paginate_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( @@ -381,11 +388,6 @@ class ObjectJournalView(View): # fall back to using base.html. if self.base_template is None: self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" - # TODO: This can be removed once an object view has been established for every model. - try: - template.loader.get_template(self.base_template) - except template.TemplateDoesNotExist: - self.base_template = 'base.html' return render(request, 'extras/object_journal.html', { 'object': obj, diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index fd4af74b0..413c8c1bc 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class IPAMConfig(AppConfig): name = "ipam" verbose_name = "IPAM" + + def ready(self): + import ipam.signals diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index e8825ad18..9dd9328b8 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -59,6 +59,11 @@ IPADDRESS_ROLES_NONUNIQUE = ( VLAN_VID_MIN = 1 VLAN_VID_MAX = 4094 +# models values for ContentTypes which may be VLANGroup scope types +VLANGROUP_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', +) + # # Services diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 5c5b9e8d3..141d50139 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -635,6 +635,14 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, choices=VLANStatusChoices, null_value=None ) + available_on_device = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + method='get_for_device' + ) + available_on_virtualmachine = django_filters.ModelChoiceFilter( + queryset=VirtualMachine.objects.all(), + method='get_for_virtualmachine' + ) tag = TagFilter() class Meta: @@ -651,6 +659,12 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, pass return queryset.filter(qs_filter) + def get_for_device(self, queryset, name, value): + return queryset.get_for_device(value) + + def get_for_virtualmachine(self, queryset, name, value): + return queryset.get_for_virtualmachine(value) + class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0262d85dd..63e093422 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup @@ -9,9 +10,10 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, - ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField, + CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from .choices import * @@ -521,12 +523,14 @@ class PrefixCSVForm(CustomFieldModelCSVForm): if data: - # Limit vlan queryset by assigned site and group - params = { - f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'), - } - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + # 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) class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -833,7 +837,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: - parent = self.instance.assigned_object.parent + parent = self.instance.assigned_object.parent_object if ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk @@ -860,18 +864,20 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. interface = self.instance.assigned_object - if interface and self.cleaned_data['primary_for_parent']: - if ipaddress.address.version == 4: - interface.parent.primary_ip4 = ipaddress - else: - interface.parent.primary_ip6 = ipaddress - interface.parent.save() - elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: - interface.parent.primary_ip4 = None - interface.parent.save() - elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: - interface.parent.primary_ip6 = None - interface.parent.save() + if interface: + parent = interface.parent_object + if self.cleaned_data['primary_for_parent']: + if ipaddress.address.version == 4: + parent.primary_ip4 = ipaddress + else: + parent.primary_ip6 = ipaddress + parent.save() + elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: + parent.primary_ip4 = None + parent.save() + elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: + parent.primary_ip6 = None + parent.save() return ipaddress @@ -1133,6 +1139,11 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False, + widget=StaticSelect2 + ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -1140,12 +1151,13 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) - site_group = DynamicModelChoiceField( + sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False, initial_params={ 'sites': '$site' - } + }, + label='Site group' ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -1155,7 +1167,7 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): }, query_params={ 'region_id': '$region', - 'group_id': '$site_group', + 'group_id': '$sitegroup', } ) location = DynamicModelChoiceField( @@ -1176,18 +1188,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 'location_id': '$location', } ) - cluster_group = DynamicModelChoiceField( + clustergroup = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, initial_params={ 'clusters': '$cluster' - } + }, + label='Cluster group' ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, query_params={ - 'group_id': '$cluster_group', + 'group_id': '$clustergroup', } ) slug = SlugField() @@ -1195,29 +1208,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VLANGroup fields = [ - 'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', 'cluster_group', - 'cluster', + 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', + 'clustergroup', 'cluster', ] + widgets = { + 'scope_type': StaticSelect2, + } def __init__(self, *args, **kwargs): instance = kwargs.get('instance') initial = kwargs.get('initial', {}) if instance is not None and instance.scope: - if type(instance.scope) is Rack: - initial['rack'] = instance.scope - elif type(instance.scope) is Location: - initial['location'] = instance.scope - elif type(instance.scope) is Site: - initial['site'] = instance.scope - elif type(instance.scope) is SiteGroup: - initial['site_group'] = instance.scope - elif type(instance.scope) is Region: - initial['region'] = instance.scope - elif type(instance.scope) is Cluster: - initial['cluster'] = instance.scope - elif type(instance.scope) is ClusterGroup: - initial['cluster_group'] = instance.scope + initial[instance.scope_type.model] = instance.scope kwargs['initial'] = initial @@ -1226,11 +1229,12 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): def clean(self): super().clean() - # Assign scope object - self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or \ - self.cleaned_data['site'] or self.cleaned_data['site_group'] or \ - self.cleaned_data['region'] or self.cleaned_data['cluster'] or \ - self.cleaned_data['cluster_group'] or None + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + self.instance.scope = self.cleaned_data.get(scope_field) + else: + self.instance.scope_id = None class VLANGroupCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/ipam/migrations/0045_vlangroup_scope.py b/netbox/ipam/migrations/0045_vlangroup_scope.py index 8795750d2..c1f3c013f 100644 --- a/netbox/ipam/migrations/0045_vlangroup_scope.py +++ b/netbox/ipam/migrations/0045_vlangroup_scope.py @@ -23,7 +23,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='vlangroup', name='scope_type', - field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), ), migrations.AlterModelOptions( name='vlangroup', diff --git a/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py new file mode 100644 index 000000000..ad636e47f --- /dev/null +++ b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def set_scope_types(apps, schema_editor): + """ + Set 'site' as the scope type for all VLANGroups with a scope ID defined. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Site = apps.get_model('dcim', 'Site') + VLANGroup = apps.get_model('ipam', 'VLANGroup') + + VLANGroup.objects.filter(scope_id__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0045_vlangroup_scope'), + ] + + operations = [ + migrations.RunPython( + code=set_scope_types + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9867f6069..b11a88d54 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -66,7 +66,7 @@ class RIR(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) + return reverse('ipam:rir', args=[self.pk]) def to_csv(self): return ( @@ -216,6 +216,9 @@ class Role(OrganizationalModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('ipam:role', args=[self.pk]) + def to_csv(self): return ( self.name, diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 26cb5299f..d0f5375e2 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,6 +9,7 @@ from dcim.models import Interface from extras.utils import extras_features from ipam.choices import * from ipam.constants import * +from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from virtualization.models import VMInterface @@ -34,9 +35,7 @@ class VLANGroup(OrganizationalModel): scope_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=Q( - model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'] - ), + limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, null=True ) @@ -70,7 +69,7 @@ class VLANGroup(OrganizationalModel): return self.name def get_absolute_url(self): - return reverse('ipam:vlangroup_vlans', args=[self.pk]) + return reverse('ipam:vlangroup', args=[self.pk]) def clean(self): super().clean() @@ -156,7 +155,7 @@ class VLAN(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() + objects = VLANQuerySet.as_manager() csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] clone_fields = [ diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 102954f13..1a723421d 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,3 +1,6 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + from utilities.querysets import RestrictedQuerySet @@ -20,3 +23,90 @@ class PrefixQuerySet(RestrictedQuerySet): 'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))', } ) + + +class VLANQuerySet(RestrictedQuerySet): + + def get_for_device(self, device): + """ + Return all VLANs available to the specified Device. + """ + from .models import VLANGroup + + # Find all relevant VLANGroups + q = Q() + if device.site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=device.site.region.get_ancestors(include_self=True) + ) + if device.site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=device.site.group.get_ancestors(include_self=True) + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=device.site_id + ) + if device.location: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'), + scope_id__in=device.location.get_ancestors(include_self=True) + ) + if device.rack: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'rack'), + scope_id=device.rack_id + ) + + # Return all applicable VLANs + return self.filter( + Q(group__in=VLANGroup.objects.filter(q)) | + Q(site=device.site) | + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) + + def get_for_virtualmachine(self, vm): + """ + Return all VLANs available to the specified VirtualMachine. + """ + from .models import VLANGroup + + # Find all relevant VLANGroups + q = Q() + if vm.cluster.site: + if vm.cluster.site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) + ) + if vm.cluster.site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True) + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=vm.cluster.site_id + ) + if vm.cluster.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), + scope_id=vm.cluster.group_id + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), + scope_id=vm.cluster_id + ) + vlan_groups = VLANGroup.objects.filter(q) + + # Return all applicable VLANs + q = ( + Q(group__in=vlan_groups) | + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) + if vm.cluster.site: + q |= Q(site=vm.cluster.site) + + return self.filter(q) diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py new file mode 100644 index 000000000..a8fce8310 --- /dev/null +++ b/netbox/ipam/signals.py @@ -0,0 +1,21 @@ +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from dcim.models import Device +from virtualization.models import VirtualMachine +from .models import IPAddress + + +@receiver(pre_delete, sender=IPAddress) +def clear_primary_ip(instance, **kwargs): + """ + When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it + was a primary IP. + """ + field_name = f'primary_ip{instance.family}' + device = Device.objects.filter(**{field_name: instance}).first() + if device: + device.save() + virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() + if virtualmachine: + virtualmachine.save() diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 2c3d95c88..54afa700e 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, - UtilizationColumn, + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF @@ -18,7 +18,7 @@ PREFIX_LINK = """ {% for i in record.parents|as_range %} {% endfor %} -{{ record.prefix }} +{{ record.prefix }} """ PREFIX_ROLE_LINK = """ @@ -112,7 +112,9 @@ VLAN_MEMBER_TAGGED = """ class VRFTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) rd = tables.Column( verbose_name='RD' ) @@ -146,7 +148,9 @@ class VRFTable(BaseTable): class RouteTargetTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tenant = TenantColumn() tags = TagColumn( url_name='ipam:vrf_list' @@ -164,13 +168,15 @@ class RouteTargetTable(BaseTable): class RIRTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) is_private = BooleanColumn( verbose_name='Private' ) aggregate_count = LinkedCountColumn( viewname='ipam:aggregate_list', - url_params={'rir': 'slug'}, + url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) actions = ButtonsColumn(RIR) @@ -187,7 +193,8 @@ class RIRTable(BaseTable): class AggregateTable(BaseTable): pk = ToggleColumn() - prefix = tables.LinkColumn( + prefix = tables.Column( + linkify=True, verbose_name='Aggregate' ) tenant = TenantColumn() @@ -224,14 +231,17 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) prefix_count = LinkedCountColumn( viewname='ipam:prefix_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Prefixes' ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='VLANs' ) actions = ButtonsColumn(Role) @@ -392,7 +402,8 @@ class InterfaceIPAddressTable(BaseTable): """ List IP addresses assigned to a specific Interface. """ - address = tables.LinkColumn( + address = tables.Column( + linkify=True, verbose_name='IP Address' ) vrf = tables.TemplateColumn( @@ -417,12 +428,13 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) + scope_type = ContentTypeColumn() scope = tables.Column( linkify=True ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', - url_params={'group': 'slug'}, + url_params={'group_id': 'pk'}, verbose_name='VLANs' ) actions = ButtonsColumn( @@ -449,9 +461,8 @@ class VLANTable(BaseTable): site = tables.Column( linkify=True ) - group = tables.LinkColumn( - viewname='ipam:vlangroup_vlans', - args=[Accessor('group__pk')] + group = tables.Column( + linkify=True ) tenant = TenantColumn() status = ChoiceFieldColumn( @@ -489,7 +500,8 @@ class VLANMembersTable(BaseTable): """ Base table for Interface and VMInterface assignments """ - name = tables.LinkColumn( + name = tables.Column( + linkify=True, verbose_name='Interface' ) tagged = tables.TemplateColumn( @@ -499,7 +511,9 @@ class VLANMembersTable(BaseTable): class VLANDevicesTable(VLANMembersTable): - device = tables.LinkColumn() + device = tables.Column( + linkify=True + ) actions = ButtonsColumn(Interface, buttons=['edit']) class Meta(BaseTable.Meta): @@ -508,7 +522,9 @@ class VLANDevicesTable(VLANMembersTable): class VLANVirtualMachinesTable(VLANMembersTable): - virtual_machine = tables.LinkColumn() + virtual_machine = tables.Column( + linkify=True + ) actions = ButtonsColumn(VMInterface, buttons=['edit']) class Meta(BaseTable.Meta): @@ -520,9 +536,8 @@ class InterfaceVLANTable(BaseTable): """ List VLANs assigned to a specific Interface. """ - vid = tables.LinkColumn( - viewname='ipam:vlan', - args=[Accessor('pk')], + vid = tables.Column( + linkify=True, verbose_name='ID' ) tagged = BooleanColumn() @@ -557,7 +572,8 @@ class ServiceTable(BaseTable): name = tables.Column( linkify=True ) - parent = tables.LinkColumn( + parent = tables.Column( + linkify=True, order_by=('device', 'virtual_machine') ) ports = tables.TemplateColumn( diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 90af26e9b..3ea54209c 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -820,12 +820,93 @@ class VLANTestCase(TestCase): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]), + Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]), + Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), + ) + Rack.objects.bulk_create(racks) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + devices = ( + Device(name='Device 1', site=sites[0], location=locations[0], rack=racks[0], device_type=device_type, device_role=device_role), + Device(name='Device 2', site=sites[1], location=locations[1], rack=racks[1], device_type=device_type, device_role=device_role), + Device(name='Device 3', site=sites[2], location=locations[2], rack=racks[2], device_type=device_type, device_role=device_role), + ) + Device.objects.bulk_create(devices) + + cluster_groups = ( + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ) + ClusterGroup.objects.bulk_create(cluster_groups) + + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], site=sites[2]), + ) + Cluster.objects.bulk_create(clusters) + + virtual_machines = ( + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]), + ) + VirtualMachine.objects.bulk_create(virtual_machines) + + groups = ( + # Scoped VLAN groups + VLANGroup(name='Region 1', slug='region-1', scope=regions[0]), + VLANGroup(name='Region 2', slug='region-2', scope=regions[1]), + VLANGroup(name='Region 3', slug='region-3', scope=regions[2]), + VLANGroup(name='Site Group 1', slug='site-group-1', scope=site_groups[0]), + VLANGroup(name='Site Group 2', slug='site-group-2', scope=site_groups[1]), + VLANGroup(name='Site Group 3', slug='site-group-3', scope=site_groups[2]), + VLANGroup(name='Site 1', slug='site-1', scope=sites[0]), + VLANGroup(name='Site 2', slug='site-2', scope=sites[1]), + VLANGroup(name='Site 3', slug='site-3', scope=sites[2]), + VLANGroup(name='Location 1', slug='location-1', scope=locations[0]), + VLANGroup(name='Location 2', slug='location-2', scope=locations[1]), + VLANGroup(name='Location 3', slug='location-3', scope=locations[2]), + VLANGroup(name='Rack 1', slug='rack-1', scope=racks[0]), + VLANGroup(name='Rack 2', slug='rack-2', scope=racks[1]), + VLANGroup(name='Rack 3', slug='rack-3', scope=racks[2]), + VLANGroup(name='Cluster Group 1', slug='cluster-group-1', scope=cluster_groups[0]), + VLANGroup(name='Cluster Group 2', slug='cluster-group-2', scope=cluster_groups[1]), + VLANGroup(name='Cluster Group 3', slug='cluster-group-3', scope=cluster_groups[2]), + VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]), + VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]), + VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]), + + # General purpose VLAN groups + VLANGroup(name='VLAN Group 1', slug='vlan-group-1'), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2'), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3'), + ) + VLANGroup.objects.bulk_create(groups) + roles = ( Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), @@ -833,13 +914,6 @@ class VLANTestCase(TestCase): ) Role.objects.bulk_create(roles) - groups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None), - ) - VLANGroup.objects.bulk_create(groups) - tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -856,12 +930,38 @@ class VLANTestCase(TestCase): Tenant.objects.bulk_create(tenants) vlans = ( - VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), - VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), - VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), - VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), - VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), - VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + # Create one VLAN per VLANGroup + VLAN(vid=1, name='Region 1', group=groups[0]), + VLAN(vid=2, name='Region 2', group=groups[1]), + VLAN(vid=3, name='Region 3', group=groups[2]), + VLAN(vid=4, name='Site Group 1', group=groups[3]), + VLAN(vid=5, name='Site Group 2', group=groups[4]), + VLAN(vid=6, name='Site Group 3', group=groups[5]), + VLAN(vid=7, name='Site 1', group=groups[6]), + VLAN(vid=8, name='Site 2', group=groups[7]), + VLAN(vid=9, name='Site 3', group=groups[8]), + VLAN(vid=10, name='Location 1', group=groups[9]), + VLAN(vid=11, name='Location 2', group=groups[10]), + VLAN(vid=12, name='Location 3', group=groups[11]), + VLAN(vid=13, name='Rack 1', group=groups[12]), + VLAN(vid=14, name='Rack 2', group=groups[13]), + VLAN(vid=15, name='Rack 3', group=groups[14]), + VLAN(vid=16, name='Cluster Group 1', group=groups[15]), + VLAN(vid=17, name='Cluster Group 2', group=groups[16]), + VLAN(vid=18, name='Cluster Group 3', group=groups[17]), + VLAN(vid=19, name='Cluster 1', group=groups[18]), + VLAN(vid=20, name='Cluster 2', group=groups[19]), + VLAN(vid=21, name='Cluster 3', group=groups[20]), + + VLAN(vid=101, name='VLAN 101', site=sites[3], group=groups[21], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), + VLAN(vid=102, name='VLAN 102', site=sites[3], group=groups[21], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), + VLAN(vid=201, name='VLAN 201', site=sites[4], group=groups[22], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), + VLAN(vid=202, name='VLAN 202', site=sites[4], group=groups[22], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), + VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + + # Create one globally available VLAN + VLAN(vid=1000, name='Global VLAN'), ) VLAN.objects.bulk_create(vlans) @@ -873,7 +973,7 @@ class VLANTestCase(TestCase): params = {'name': ['VLAN 101', 'VLAN 102']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rd(self): + def test_vid(self): params = {'vid': ['101', '201', '301']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) @@ -892,14 +992,14 @@ class VLANTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} + sites = Site.objects.all() + params = {'site_id': [sites[3].pk, sites[4].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'site': [sites[0].slug, sites[1].slug]} + params = {'site': [sites[3].slug, sites[4].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_group(self): - groups = VLANGroup.objects.all()[:2] + groups = VLANGroup.objects.filter(name__startswith='VLAN Group')[:2] params = {'group_id': [groups[0].pk, groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'group': [groups[0].slug, groups[1].slug]} @@ -913,7 +1013,7 @@ class VLANTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): - params = {'status': [VLANStatusChoices.STATUS_ACTIVE, VLANStatusChoices.STATUS_DEPRECATED]} + params = {'status': [VLANStatusChoices.STATUS_DEPRECATED, VLANStatusChoices.STATUS_RESERVED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_tenant(self): @@ -930,6 +1030,16 @@ class VLANTestCase(TestCase): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_available_on_device(self): + device_id = Device.objects.first().pk + params = {'available_on_device': device_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + + def test_available_on_virtualmachine(self): + vm_id = VirtualMachine.objects.first().pk + params = {'available_on_virtualmachine': vm_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + class ServiceTestCase(TestCase): queryset = Service.objects.all() diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 387bdd2b5..2704ac1ca 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -329,7 +329,6 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', - 'site': sites[1].pk, 'description': 'A new VLAN group', } diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 4b576d21f..262537b8b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path('rirs//', views.RIRView.as_view(), name='rir'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), @@ -59,6 +60,7 @@ urlpatterns = [ path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path('roles//', views.RoleView.as_view(), name='role'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), @@ -97,9 +99,9 @@ urlpatterns = [ path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d5332129c..0339aff07 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,11 +1,10 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from dcim.models import Device, Interface from netbox.views import generic -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables @@ -149,6 +148,23 @@ class RIRListView(generic.ObjectListView): template_name = 'ipam/rir_list.html' +class RIRView(generic.ObjectView): + queryset = RIR.objects.all() + + def get_extra_context(self, request, instance): + aggregates = Aggregate.objects.restrict(request.user, 'view').filter( + rir=instance + ) + + aggregates_table = tables.AggregateTable(aggregates) + aggregates_table.columns.hide('rir') + paginate_table(aggregates_table, request) + + return { + 'aggregates_table': aggregates_table, + } + + class RIREditView(generic.ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm @@ -231,12 +247,7 @@ class AggregateView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -292,6 +303,23 @@ class RoleListView(generic.ObjectListView): table = tables.RoleTable +class RoleView(generic.ObjectView): + queryset = Role.objects.all() + + def get_extra_context(self, request, instance): + prefixes = Prefix.objects.restrict(request.user, 'view').filter( + role=instance + ) + + prefixes_table = tables.PrefixTable(prefixes) + prefixes_table.columns.hide('role') + paginate_table(prefixes_table, request) + + return { + 'prefixes_table': prefixes_table, + } + + class RoleEditView(generic.ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm @@ -373,7 +401,7 @@ class PrefixView(generic.ObjectView): class PrefixPrefixesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_prefixes.html' + template_name = 'ipam/prefix/prefixes.html' def get_extra_context(self, request, instance): # Child prefixes table @@ -388,12 +416,7 @@ class PrefixPrefixesView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -416,7 +439,7 @@ class PrefixPrefixesView(generic.ObjectView): class PrefixIPAddressesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_ipaddresses.html' + template_name = 'ipam/prefix/ip_addresses.html' def get_extra_context(self, request, instance): # Find all IPAddresses belonging to this Prefix @@ -431,12 +454,7 @@ class PrefixIPAddressesView(generic.ObjectView): ip_table = tables.IPAddressTable(ipaddresses) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): ip_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(ip_table) + paginate_table(ip_table, request) # Compile permissions list for rendering the object table permissions = { @@ -534,12 +552,6 @@ class IPAddressView(generic.ObjectView): ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(related_ips_table) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, @@ -655,6 +667,29 @@ class VLANGroupListView(generic.ObjectListView): table = tables.VLANGroupTable +class VLANGroupView(generic.ObjectView): + queryset = VLANGroup.objects.all() + + def get_extra_context(self, request, instance): + vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( + Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) + ) + vlans_count = vlans.count() + vlans = add_available_vlans(instance, vlans) + + vlans_table = tables.VLANDetailTable(vlans) + if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): + vlans_table.columns.show('pk') + vlans_table.columns.hide('site') + vlans_table.columns.hide('group') + paginate_table(vlans_table, request) + + return { + 'vlans_count': vlans_count, + 'vlans_table': vlans_table, + } + + class VLANGroupEditView(generic.ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm @@ -688,43 +723,6 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView): table = tables.VLANGroupTable -class VLANGroupVLANsView(generic.ObjectView): - queryset = VLANGroup.objects.all() - template_name = 'ipam/vlangroup_vlans.html' - - def get_extra_context(self, request, instance): - vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( - Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) - ) - vlans = add_available_vlans(instance, vlans) - - vlan_table = tables.VLANDetailTable(vlans) - if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): - vlan_table.columns.show('pk') - vlan_table.columns.hide('site') - vlan_table.columns.hide('group') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request), - } - RequestConfig(request, paginate).configure(vlan_table) - - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_vlan'), - 'change': request.user.has_perm('ipam.change_vlan'), - 'delete': request.user.has_perm('ipam.delete_vlan'), - } - - return { - 'first_available_vlan': instance.get_next_available_vid(), - 'bulk_querystring': f'group_id={instance.pk}', - 'vlan_table': vlan_table, - 'permissions': permissions, - } - - # # VLANs # @@ -753,17 +751,12 @@ class VLANView(generic.ObjectView): class VLANInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_interfaces.html' + template_name = 'ipam/vlan/interfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_interfaces().prefetch_related('device') members_table = tables.VLANDevicesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, @@ -773,17 +766,12 @@ class VLANInterfacesView(generic.ObjectView): class VLANVMInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_vminterfaces.html' + template_name = 'ipam/vlan/vminterfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine') members_table = tables.VLANVirtualMachinesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 78ab7431d..334ee09f7 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from .routers import OrderedDefaultRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -9,7 +9,6 @@ __all__ = ( 'ContentTypeField', 'OrderedDefaultRouter', 'SerializedPKRelatedField', - 'TimeZoneField', 'ValidatedModelSerializer', 'WritableNestedSerializer', ) diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index fb3eef76f..d73cbcac2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -104,21 +104,6 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" -class TimeZoneField(serializers.Field): - """ - Represent a pytz time zone. - """ - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - if data not in pytz.common_timezones: - raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) - return pytz.timezone(data) - - class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index b7a72a504..c40e280dd 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -34,6 +34,9 @@ REDIS = { 'PASSWORD': '', 'DATABASE': 0, 'SSL': False, + # Set this to True to skip TLS certificate verification + # This can expose the connection to attacks, be careful + # 'INSECURE_SKIP_TLS_VERIFY': False, }, 'caching': { 'HOST': 'localhost', @@ -44,6 +47,9 @@ REDIS = { 'PASSWORD': '', 'DATABASE': 1, 'SSL': False, + # Set this to True to skip TLS certificate verification + # This can expose the connection to attacks, be careful + # 'INSECURE_SKIP_TLS_VERIFY': False, } } @@ -154,6 +160,9 @@ LOGIN_TIMEOUT = None # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False +# The URL to use when mapping physical addresses or GPS coordinates +MAPS_URL = 'https://maps.google.com/?q=' + # An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. # "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request # all objects by specifying "?limit=0". diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e5b3f763c..5568f4e70 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,10 +1,8 @@ from collections import OrderedDict -from django.db.models import Count - -from circuits.filters import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, Provider -from circuits.tables import CircuitTable, ProviderTable +from circuits.filters import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +from circuits.models import Circuit, ProviderNetwork, Provider +from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable from dcim.filters import ( CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet, @@ -42,11 +40,17 @@ SEARCH_TYPES = OrderedDict(( ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate_sites(), + ), 'filterset': CircuitFilterSet, 'table': CircuitTable, 'url': 'circuits:circuit_list', }), + ('providernetwork', { + 'queryset': ProviderNetwork.objects.prefetch_related('provider'), + 'filterset': ProviderNetworkFilterSet, + 'table': ProviderNetworkTable, + 'url': 'circuits:providernetwork_list', + }), # DCIM ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 0e66ba90d..fce15c1f7 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -195,6 +195,15 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTMo def __str__(self): return self.name + def clean(self): + super().clean() + + # An MPTT model cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) + class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): """ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5c727756f..24a6455a5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.11-beta1' +VERSION = '2.11.0-dev' # Hostname HOSTNAME = platform.node() @@ -94,10 +94,9 @@ LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) +MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') -STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) -STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') @@ -124,18 +123,23 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') +STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) +STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') # Validate update repo URL and timeout if RELEASE_CHECK_URL: - try: - URLValidator(RELEASE_CHECK_URL) - except ValidationError: - raise ImproperlyConfigured( + validator = URLValidator( + message=( "RELEASE_CHECK_URL must be a valid API URL. Example: " "https://api.github.com/repos/netbox-community/netbox" ) + ) + try: + validator(RELEASE_CHECK_URL) + except ValidationError as err: + raise ImproperlyConfigured(str(err)) # Enforce a minimum cache timeout for update checks if RELEASE_CHECK_TIMEOUT < 3600: @@ -217,6 +221,7 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10) TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) +TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False) # Caching if 'caching' not in REDIS: @@ -235,6 +240,7 @@ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default' CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) +CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False) # @@ -403,21 +409,14 @@ if CACHING_REDIS_USING_SENTINEL: 'password': CACHING_REDIS_PASSWORD, } else: - if CACHING_REDIS_SSL: - REDIS_CACHE_CON_STRING = 'rediss://' - else: - REDIS_CACHE_CON_STRING = 'redis://' - - if CACHING_REDIS_PASSWORD: - REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) - - REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( - REDIS_CACHE_CON_STRING, - CACHING_REDIS_HOST, - CACHING_REDIS_PORT, - CACHING_REDIS_DATABASE - ) - CACHEOPS_REDIS = REDIS_CACHE_CON_STRING + CACHEOPS_REDIS = { + 'host': CACHING_REDIS_HOST, + 'port': CACHING_REDIS_PORT, + 'db': CACHING_REDIS_DATABASE, + 'password': CACHING_REDIS_PASSWORD, + 'ssl': CACHING_REDIS_SSL, + 'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required', + } if not CACHE_TIMEOUT: CACHEOPS_ENABLED = False @@ -565,6 +564,7 @@ else: 'DB': TASKS_REDIS_DATABASE, 'PASSWORD': TASKS_REDIS_PASSWORD, 'SSL': TASKS_REDIS_SSL, + 'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required', 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, } diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index ccd349678..478957e6b 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -16,7 +16,7 @@ from packaging import version from circuits.models import Circuit, Provider from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, + Cable, ConsolePort, Device, DeviceType, Interface, Location, PowerPanel, PowerFeed, PowerPort, Rack, Site, ) from extras.choices import JobResultStatusChoices from extras.models import ObjectChange, JobResult diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index e27447ad0..8f713fa63 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -14,7 +14,6 @@ from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View -from django_tables2 import RequestConfig from django_tables2.export import TableExport from extras.models import CustomField, ExportTemplate @@ -23,8 +22,8 @@ from utilities.exceptions import AbortTransaction from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, ) -from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -183,7 +182,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if request.GET.get('export') == 'table': exclude_columns = {'pk'} exclude_columns.update({ - col for col in table.base_columns if col not in table.visible_columns + name for name, _ in table.available_columns }) exporter = TableExport( export_format=TableExport.CSV, @@ -195,12 +194,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' ) - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) + # Paginate the objects table + paginate_table(table, request) context = { 'content_type': content_type, diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index f189adc1b..b95100acc 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -158,11 +158,9 @@ $(document).ready(function() { q: params.term, limit: 50, offset: offset, + brief: true, }; - // Allow for controlling the brief setting from within APISelect - parameters.brief = ( $(element).is('[data-full]') ? undefined : true ); - // Attach any extra query parameters $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-query-param-")){ @@ -213,26 +211,7 @@ $(document).ready(function() { // The disabled-indicator equated to true, so we disable this option record.disabled = true; } - - if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; - results[record.site.name + ":" + record.group.name].children.push(record); - } - else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; - results[record.group.name].children.push(record); - } - else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; - results[record.site.name].children.push(record); - } - else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] }; - results['global'].children.push(record); - } - else { - results[idx] = record - } + results[idx] = record; return results; },Object.create(null)); diff --git a/netbox/project-static/js/tableconfig.js b/netbox/project-static/js/tableconfig.js index 8f4692ea4..6851d2e8c 100644 --- a/netbox/project-static/js/tableconfig.js +++ b/netbox/project-static/js/tableconfig.js @@ -1,9 +1,27 @@ $(document).ready(function() { - $('form.userconfigform input.reset').click(function(event) { - // Deselect all columns when the reset button is clicked + + // Select or reset table columns + $('#save_tableconfig').click(function(event) { + $('select[name="columns"] option').attr("selected", "selected"); + }); + $('#reset_tableconfig').click(function(event) { $('select[name="columns"]').val([]); }); + // Swap columns between available and selected lists + $('#add_columns').click(function(e) { + let selected_columns = $('#id_available_columns option:selected'); + $('#id_columns').append($(selected_columns).clone()); + $(selected_columns).remove(); + e.preventDefault(); + }); + $('#remove_columns').click(function(e) { + let selected_columns = $('#id_columns option:selected'); + $('#id_available_columns').append($(selected_columns).clone()); + $(selected_columns).remove(); + e.preventDefault(); + }); + $('form.userconfigform').submit(function(event) { event.preventDefault(); diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 932242060..dc3a65747 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -263,7 +263,7 @@ class SecretRole(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) + return reverse('secrets:secretrole', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index dd91985ec..7e164920a 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -10,10 +10,12 @@ from .models import SecretRole, Secret class SecretRoleTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) secret_count = LinkedCountColumn( viewname='secrets:secret_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Secrets' ) actions = ButtonsColumn(SecretRole) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 7c72b848c..a47070152 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path('secret-roles//', views.SecretRoleView.as_view(), name='secretrole'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 1bc54a3b6..cc0504329 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -7,6 +7,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from netbox.views import generic +from utilities.tables import paginate_table from utilities.utils import count_related from . import filters, forms, tables from .models import SecretRole, Secret, SessionKey, UserKey @@ -33,6 +34,23 @@ class SecretRoleListView(generic.ObjectListView): table = tables.SecretRoleTable +class SecretRoleView(generic.ObjectView): + queryset = SecretRole.objects.all() + + def get_extra_context(self, request, instance): + secrets = Secret.objects.restrict(request.user, 'view').filter( + role=instance + ) + + secrets_table = tables.SecretTable(secrets) + secrets_table.columns.hide('role') + paginate_table(secrets_table, request) + + return { + 'secrets_table': secrets_table, + } + + class SecretRoleEditView(generic.ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 580f11f3c..ca696229f 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -4,7 +4,7 @@ {% block breadcrumbs %} - + {% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index e0fc564d3..14ed99be8 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -6,7 +6,7 @@ {% block form %}
    -
    Location
    +
    Termination
    @@ -26,10 +26,23 @@

    {{ form.term_side.value }}

    - {% render_field form.region %} - {% render_field form.site_group %} - {% render_field form.site %} {% render_field form.mark_connected %} + {% with providernetwork_tab_active=form.initial.provider_network %} + +
    +
    + {% render_field form.region %} + {% render_field form.site_group %} + {% render_field form.site %} +
    +
    + {% render_field form.provider_network %} +
    +
    + {% endwith %}
    diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html new file mode 100644 index 000000000..aee7bf944 --- /dev/null +++ b/netbox/templates/circuits/circuittype.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Circuit Types
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Circuit Type +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Circuits + {{ circuits_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} + {% if perms.circuits.add_circuit %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 041cf6dde..4369ccaeb 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -79,10 +79,16 @@
    {% endif %} - Not defined - {% endif %} - - + + + {% else %} + + Provider Network + + {{ termination.provider_network }} + + + {% endif %} Speed @@ -96,21 +102,6 @@ {% endif %} - - IP Addressing - - {% if termination.connected_endpoint %} - {% for ip in termination.ip_addresses %} - {% if not forloop.first %}
    {% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - - {% endif %} - - Cross-Connect {{ termination.xconnect_id|placeholder }} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 6389afad8..5628fa26e 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -52,6 +52,9 @@ + {% plugin_left_page object %} + +
    {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
    @@ -66,7 +69,7 @@ {% endif %}
    - {% plugin_left_page object %} + {% plugin_right_page object %}
    @@ -84,8 +87,7 @@
    {% endif %}
    - {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} - {% plugin_right_page object %} + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
    diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html new file mode 100644 index 000000000..22ab3a7c0 --- /dev/null +++ b/netbox/templates/circuits/providernetwork.html @@ -0,0 +1,72 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Provider Networks
  • +
  • {{ object.provider }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Provider Network +
    + + + + + + + + + + + + + +
    Provider + {{ object.provider }} +
    Name{{ object.name }}
    Description{{ object.description }}
    +
    + {% plugin_left_page object %} +
    +
    +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 1a8667787..b4e3e8d43 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -27,6 +27,8 @@ {# Cable #} {% if cable %} {% include 'dcim/trace/cable.html' %} + {% elif far_end %} + {% include 'dcim/trace/attachment.html' %} {% endif %} {# Far end #} @@ -43,6 +45,8 @@ {% if forloop.last %} {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} {% endif %} + {% elif far_end %} + {% include 'dcim/trace/object.html' with object=far_end %} {% endif %} {% if forloop.last %} @@ -69,7 +73,7 @@
    Total segments: {{ traced_path|length }}
    Total length: {% if total_length %} - {{ total_length|floatformat:"-2" }} Meters / + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / {{ total_length|meters_to_feet|floatformat:"-2" }} Feet {% else %} N/A diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 271c7c42d..585c1b62c 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 8b9ffde8a..819d9025c 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Server Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 6e0e89503..60214934d 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -7,7 +7,7 @@ {% block breadcrumbs %} - + {% if object.parent_bay %} diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 2301617bf..9e438602f 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -46,6 +46,7 @@
    + {% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} {% table_config_form consoleport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index dd2eccec6..6a5dffb70 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -46,6 +46,7 @@
    + {% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} {% table_config_form consoleserverport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 32e4435cf..1e10672fd 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -43,6 +43,7 @@
    + {% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} {% table_config_form devicebay_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index fd2c80381..07ab8d541 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -46,6 +46,7 @@ + {% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} {% table_config_form frontport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index bce178a30..7913782e8 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -49,6 +49,7 @@ + {% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} {% table_config_form interface_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 2d74758d8..3ae0855f5 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -43,6 +43,7 @@ + {% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} {% table_config_form inventoryitem_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index a973dd6ff..25590a836 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -46,6 +46,7 @@ + {% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} {% table_config_form poweroutlet_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index ea9a3578d..82d3c98d1 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -46,6 +46,7 @@ + {% include 'inc/paginator.html' with paginator=powerport_table.paginator page=powerport_table.page %} {% table_config_form powerport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index b2b721046..4150e58dd 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -46,6 +46,7 @@ + {% include 'inc/paginator.html' with paginator=rearport_table.paginator page=rearport_table.page %} {% table_config_form rearport_table %} {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index f0e389c54..568bd3f0e 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Device Bays
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html new file mode 100644 index 000000000..c6cbf4952 --- /dev/null +++ b/netbox/templates/dcim/devicerole.html @@ -0,0 +1,76 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Device Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Device Role +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    VM Role + {% if object.vm_role %} + + {% else %} + + {% endif %} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index e94abf49c..64e8580ca 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -7,7 +7,7 @@ {% block breadcrumbs %} - + {% endblock %} @@ -67,7 +67,7 @@ - + diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 5a84889ab..28e5c160c 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Front Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/inc/device_napalm_tabs.html b/netbox/templates/dcim/inc/device_napalm_tabs.html deleted file mode 100644 index 56d069304..000000000 --- a/netbox/templates/dcim/inc/device_napalm_tabs.html +++ /dev/null @@ -1,15 +0,0 @@ -{% if not disabled_message %} - - - -{% else %} - - - -{% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 26d05afb7..1c515cad8 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -3,6 +3,21 @@ {% load plugins %} {% load render_table from django_tables2 %} +{% block breadcrumbs %} + {{ block.super }} +
  • Interfaces
  • +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.add_interface and not object.is_virtual %} + + Add Child Interface + + {% endif %} + {{ block.super }} +{% endblock %} + {% block content %}
    @@ -278,6 +293,11 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
    +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} +
    +
    {% plugin_full_width_page object %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 6eec7f434..35aace971 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Inventory Items
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html new file mode 100644 index 000000000..a5eeb4e71 --- /dev/null +++ b/netbox/templates/dcim/location.html @@ -0,0 +1,97 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Locations
  • + {% for location in object.get_ancestors %} +
  • {{ location }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Location +
    +
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer }}
    Model Name
    + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Site{{ object.site }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Racks + {{ object.racks.count }} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} +
    +
    + Images +
    + {% include 'inc/image_attachments.html' with images=object.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
    + {% plugin_right_page object %} +
    + +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html new file mode 100644 index 000000000..b2ecacbb1 --- /dev/null +++ b/netbox/templates/dcim/manufacturer.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Manufacturers
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Manufacturer +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Device types + {{ devicetypes_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Device Types +
    + {% include 'inc/table.html' with table=devicetypes_table %} + {% if perms.dcim.add_devicetype %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html new file mode 100644 index 000000000..f620bc4c9 --- /dev/null +++ b/netbox/templates/dcim/platform.html @@ -0,0 +1,78 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Platforms
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Platform +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Manufacturer + {% if object.manufacturer %} + {{ object.manufacturer }} + {% else %} + None + {% endif %} +
    NAPALM Driver{{ object.napalm_driver|placeholder }}
    NAPALM Arguments
    {{ object.napalm_args }}
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index e76f067b1..9a11628c8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Outlets
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index febab7e0c..2cfdde243 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block breadcrumbs %} @@ -49,7 +50,37 @@
    - {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
    + {% csrf_token %} +
    + {% render_table powerfeed_table 'inc/table.html' %} + +
    +
    {% plugin_full_width_page object %}
    diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 635251b0d..1a7cc24e7 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 3ff088b1c..7bd914d4a 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -8,7 +8,7 @@ {% block breadcrumbs %} - + {% if object.group %} {% for group in object.group.get_ancestors %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 2affb7946..06e14e7db 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -42,7 +42,7 @@ Group {% if rack.group %} - {{ rack.group }} + {{ rack.group }} {% else %} None {% endif %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html new file mode 100644 index 000000000..89306b481 --- /dev/null +++ b/netbox/templates/dcim/rackrole.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Rack Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Rack Role +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Racks + {{ racks_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Racks +
    + {% include 'inc/table.html' with table=racks_table %} + {% if perms.dcim.add_rack %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 01eb0e9e6..eb9452a0c 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Rear Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html new file mode 100644 index 000000000..1e2d395dd --- /dev/null +++ b/netbox/templates/dcim/region.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Region
  • + {% for region in object.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Region +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    + Child Regions +
    + {% include 'inc/table.html' with table=child_regions_table %} + {% if perms.dcim.add_region %} + + {% endif %} +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1fd0e71f4..ceef169b9 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -92,7 +92,7 @@ {% if object.physical_address %} @@ -111,7 +111,7 @@ {% if object.latitude and object.longitude %} @@ -172,27 +172,27 @@
    @@ -210,7 +210,12 @@ {{ location }} - {{ location.rack_count }} + + {{ location.rack_count }} + + + {{ location.device_count }} + diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html new file mode 100644 index 000000000..2de17c025 --- /dev/null +++ b/netbox/templates/dcim/sitegroup.html @@ -0,0 +1,86 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Site Groups
  • + {% for sitegroup in object.get_ancestors %} +
  • {{ sitegroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Site Group +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    + Child Groups +
    + {% include 'inc/table.html' with table=child_groups_table %} + {% if perms.dcim.add_sitegroup %} + + {% endif %} +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/trace/attachment.html b/netbox/templates/dcim/trace/attachment.html new file mode 100644 index 000000000..450d74bc8 --- /dev/null +++ b/netbox/templates/dcim/trace/attachment.html @@ -0,0 +1,5 @@ +{% load helpers %} + +
    + Attachment +
    diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html new file mode 100644 index 000000000..72e5b5787 --- /dev/null +++ b/netbox/templates/dcim/trace/object.html @@ -0,0 +1,3 @@ + diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 453c9ca61..dfcce3cf3 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -7,7 +7,7 @@ {% block breadcrumbs %} {% if object.master %} - + {% endif %} {% endblock %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 9673f9f44..4e4506dc6 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -17,6 +17,7 @@ {% render_field form.regions %} {% render_field form.site_groups %} {% render_field form.sites %} + {% render_field form.device_types %} {% render_field form.roles %} {% render_field form.platforms %} {% render_field form.cluster_groups %} diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html new file mode 100644 index 000000000..f64741f36 --- /dev/null +++ b/netbox/templates/extras/journalentry.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load static %} + +{% block breadcrumbs %} +
  • Journal Entries
  • +
  • {{ object.assigned_object }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Journal Entry +
    + + + + + + + + + + + + + + + + + +
    Object + {{ object.assigned_object }} +
    Created + {{ object.created }} +
    Created By + {{ object.created_by }} +
    Kind + {{ object.get_kind_display }} +
    +
    +
    +
    +
    +
    + Comments +
    +
    + {{ object.comments|render_markdown }} +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 76a34c060..f2c5edf23 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -27,7 +27,7 @@
    {% endif %} -

    {{ report.name }}

    +

    {{ report.name }}

    {% if report.description %}

    {{ report.description }}

    {% endif %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 5808f707f..7a99d245d 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,8 +15,8 @@
    -

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script }}

    +

    {{ script.Meta.description|render_markdown }}

    -

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script }}

    +

    {{ script.Meta.description|render_markdown }}

    {% endblock %} -{% block content %}{% block tab_buttons %}{% endblock %}{% endblock %} +{% block content %} +

    + + Created {{ object.created }} · + Updated {{ object.last_updated|timesince }} ago + + {{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }} +

    +{% block tab_buttons %}{% custom_links object %}{% endblock %} +{% endblock %} {% block components %}{% endblock %} - -{% comment %} {% block header %} -
    -
    - -
    -
    - {# TODO: Provide absolute search URL #} -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% block buttons %} - {% if request.user|can_add:object %} - {% clone_button object %} - {% endif %} - {% if request.user|can_change:object %} - {% edit_button object %} - {% endif %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} - {% endblock %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - - {% include 'inc/created_updated.html' %} -
    - {% block tab_buttons %} - {% custom_links object %} - {% endblock %} -
    - -{% endblock %} {% endcomment %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 01694abf6..8cc23637b 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -1,6 +1,7 @@ {% extends 'layout.html' %} {% load buttons %} {% load helpers %} +{% load render_table from django_tables2 %} {% load static %} {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %} @@ -39,36 +40,37 @@
    {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} - {% if permissions.change or permissions.delete %} -
    - {% csrf_token %} - - {% if table.paginator.num_pages > 1 %} -