mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 01:06:11 -06:00
Merge remote-tracking branch 'origin/develop' into feat/11738-vlan-group-utilization
This commit is contained in:
commit
43e29c746e
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.0
|
||||
placeholder: v3.5.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
11
.github/ISSUE_TEMPLATE/config.yml
vendored
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -3,10 +3,13 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Contributing Policy
|
||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||
about: "Please read through our contributing policy before opening an issue or pull request"
|
||||
about: "Please read through our contributing policy before opening an issue or pull request."
|
||||
- name: ❓ Discussion
|
||||
url: https://github.com/netbox-community/netbox/discussions
|
||||
about: "If you're just looking for help, try starting a discussion instead"
|
||||
about: "If you're just looking for help, try starting a discussion instead."
|
||||
- name: 💡 Plugin Idea
|
||||
url: https://plugin-ideas.netbox.dev
|
||||
about: "Have an idea for a plugin? Head over to the ideas board!"
|
||||
- name: 💬 Community Slack
|
||||
url: https://netdev.chat/
|
||||
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"
|
||||
url: https://netdev.chat
|
||||
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.0
|
||||
placeholder: v3.5.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -1,11 +1,10 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
|
||||
The premiere source of truth powering network automation
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By
|
||||
combining the traditional disciplines of IP address management (IPAM) and
|
||||
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
||||
|
@ -84,7 +84,8 @@ feedparser
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django/releases
|
||||
graphene_django
|
||||
# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
|
||||
graphene_django==3.0.0
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://docs.gunicorn.org/en/latest/news.html
|
||||
|
@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
|
||||
```
|
||||
>>> lab1 = Site.objects.get(pk=7)
|
||||
>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
|
||||
>>> myvlan.full_clean()
|
||||
>>> myvlan.save()
|
||||
```
|
||||
|
||||
Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
|
||||
|
||||
```
|
||||
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
|
||||
```
|
||||
|
||||
To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
|
||||
|
||||
```
|
||||
@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
|
||||
>>> vlan.name
|
||||
'MyNewVLAN'
|
||||
>>> vlan.name = 'BetterName'
|
||||
>>> vlan.full_clean()
|
||||
>>> vlan.save()
|
||||
>>> VLAN.objects.get(pk=1280).name
|
||||
'BetterName'
|
||||
|
@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
---
|
||||
|
||||
## BANNER_MAINTENANCE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
|
||||
|
||||
---
|
||||
|
||||
## BANNER_TOP
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
|
||||
|
||||
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.
|
||||
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. Set this to `None` to disable the "map it" button within the UI.
|
||||
|
||||
---
|
||||
|
||||
@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
|
||||
Default: `300`
|
||||
|
||||
The maximum execution time of a background task (such as running a custom script), in seconds.
|
||||
|
||||
---
|
||||
|
||||
## RQ_RETRY_INTERVAL
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `60`
|
||||
|
||||
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
|
||||
|
||||
---
|
||||
|
||||
## RQ_RETRY_MAX
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `0` (retries disabled)
|
||||
|
||||
The maximum number of times a background task will be retried before being marked as failed.
|
||||
|
@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_GROUPS
|
||||
|
||||
Default: `False`
|
||||
|
||||
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
Default: `False`
|
||||
|
@ -378,6 +378,7 @@ class NewBranchScript(Script):
|
||||
slug=slugify(data['site_name']),
|
||||
status=SiteStatusChoices.STATUS_PLANNED
|
||||
)
|
||||
site.full_clean()
|
||||
site.save()
|
||||
self.log_success(f"Created new site: {site}")
|
||||
|
||||
@ -391,6 +392,7 @@ class NewBranchScript(Script):
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.full_clean()
|
||||
switch.save()
|
||||
self.log_success(f"Created new switch: {switch}")
|
||||
|
||||
|
@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
|
||||
```
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
|
||||
sudo groupadd --system netbox
|
||||
sudo adduser --system -g netbox netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
On CentOS:
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y openldap-devel
|
||||
sudo yum install -y openldap-devel python3-devel
|
||||
```
|
||||
|
||||
### Install django-auth-ldap
|
||||
|
@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
|
||||
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
|
||||
|
||||
## Endpoint Hierarchy
|
||||
|
||||
|
@ -69,10 +69,11 @@ Defines how filters are evaluated against custom field values.
|
||||
Controls how and whether the custom field is displayed within the NetBox user interface.
|
||||
|
||||
| Option | Description |
|
||||
|------------|--------------------------------------|
|
||||
|-------------------|--------------------------------------------------|
|
||||
| Read/write | Display and permit editing (default) |
|
||||
| Read-only | Display field but disallow editing |
|
||||
| Hidden | Do not display field in the UI |
|
||||
| Hidden (if unset) | Display in the UI only when a value has been set |
|
||||
|
||||
### Default
|
||||
|
||||
|
@ -1,15 +1,94 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5.1 (FUTURE)
|
||||
## v3.5.4 (FUTURE)
|
||||
|
||||
## Enhancements
|
||||
---
|
||||
|
||||
* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions
|
||||
* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds
|
||||
* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view
|
||||
## v3.5.3 (2023-06-02)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
|
||||
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
|
||||
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
|
||||
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
|
||||
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
|
||||
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
|
||||
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
|
||||
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
|
||||
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
|
||||
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
|
||||
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
|
||||
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
|
||||
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
|
||||
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
|
||||
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
|
||||
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
|
||||
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
|
||||
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
|
||||
|
||||
---
|
||||
|
||||
## v3.5.2 (2023-05-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
|
||||
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
|
||||
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
|
||||
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
|
||||
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
|
||||
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
|
||||
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
|
||||
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
|
||||
* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
|
||||
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
|
||||
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
|
||||
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
|
||||
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
|
||||
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
|
||||
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
|
||||
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
|
||||
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
|
||||
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
|
||||
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
|
||||
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
|
||||
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
|
||||
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
|
||||
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
|
||||
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
|
||||
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
|
||||
|
||||
---
|
||||
|
||||
## v3.5.1 (2023-05-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions
|
||||
* [#11190](https://github.com/netbox-community/netbox/issues/11190) - Including systemd service & timer configurations for housekeeping tasks
|
||||
* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds
|
||||
* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view
|
||||
* [#11652](https://github.com/netbox-community/netbox/issues/11652) - Add a module status column to module bay tables
|
||||
* [#11791](https://github.com/netbox-community/netbox/issues/11791) - Enable configuration of custom database backend via `ENGINE` parameter
|
||||
* [#11801](https://github.com/netbox-community/netbox/issues/11801) - Include device description within rack elevation tooltip
|
||||
* [#11932](https://github.com/netbox-community/netbox/issues/11932) - Introduce a list view for image attachments, orderable by date and other attributes
|
||||
* [#12122](https://github.com/netbox-community/netbox/issues/12122) - Enable bulk import oj journal entries
|
||||
* [#12245](https://github.com/netbox-community/netbox/issues/12245) - Enable the assignment of wireless LANs to interfaces under bulk edit
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10757](https://github.com/netbox-community/netbox/issues/10757) - Simplify IP address interface and NAT IP assignment form fields to avoid confusion
|
||||
* [#11715](https://github.com/netbox-community/netbox/issues/11715) - Prefix within a VRF should list global prefixes as parents only if they are containers
|
||||
* [#12363](https://github.com/netbox-community/netbox/issues/12363) - Fix whitespace for paragraph elements in Markdown-rendered table columns
|
||||
* [#12367](https://github.com/netbox-community/netbox/issues/12367) - Fix `RelatedObjectDoesNotExist` exception under certain conditions (regression from #11550)
|
||||
* [#12380](https://github.com/netbox-community/netbox/issues/12380) - Allow selecting object change as model under object list widget configuration
|
||||
* [#12384](https://github.com/netbox-community/netbox/issues/12384) - Add a three-second timeout for RSS reader widget
|
||||
@ -19,10 +98,16 @@
|
||||
* [#12401](https://github.com/netbox-community/netbox/issues/12401) - Support the creation of front ports without a pre-populated device ID
|
||||
* [#12405](https://github.com/netbox-community/netbox/issues/12405) - Fix filtering for VLAN groups displayed under site view
|
||||
* [#12410](https://github.com/netbox-community/netbox/issues/12410) - Fix base path for OpenAPI schema (fixes Swagger UI requests)
|
||||
* [#12416](https://github.com/netbox-community/netbox/issues/12416) - Fix `FileNotFoundError` exception when a managed script file is missing from disk
|
||||
* [#12412](https://github.com/netbox-community/netbox/issues/12412) - Device/VM interface MAC addresses can be nullified via REST API
|
||||
* [#12415](https://github.com/netbox-community/netbox/issues/12415) - Fix `ImportError` exception when running RQ worker
|
||||
* [#12433](https://github.com/netbox-community/netbox/issues/12433) - Correct the application of URL query parameters for object list dashboard widgets
|
||||
* [#12436](https://github.com/netbox-community/netbox/issues/12436) - Remove extraneous "add" button from contact assignments list
|
||||
* [#12463](https://github.com/netbox-community/netbox/issues/12463) - Fix the association of completed jobs with reports & scripts in the REST API
|
||||
* [#12464](https://github.com/netbox-community/netbox/issues/12464) - Apply credentials for git data source only when connecting via HTTP/S
|
||||
* [#12476](https://github.com/netbox-community/netbox/issues/12476) - Fix `TypeError` exception when running the `runscript` management command
|
||||
* [#12483](https://github.com/netbox-community/netbox/issues/12483) - Fix git remote data syncing when with HTTP proxies defined
|
||||
* [#12496](https://github.com/netbox-community/netbox/issues/12496) - Remove obsolete account field from provider UI view
|
||||
|
||||
---
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import register_model_view
|
||||
@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
@register_model_view(Provider, 'contacts')
|
||||
class ProviderContactsView(ObjectContactsView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# ProviderAccounts
|
||||
#
|
||||
@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ProviderAccountTable
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount, 'contacts')
|
||||
class ProviderAccountContactsView(ObjectContactsView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Provider networks
|
||||
#
|
||||
@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'contacts')
|
||||
class CircuitContactsView(ObjectContactsView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Circuit terminations
|
||||
#
|
||||
|
@ -12,7 +12,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from dulwich import porcelain
|
||||
from dulwich.config import StackedConfig
|
||||
from dulwich.config import ConfigDict
|
||||
|
||||
from netbox.registry import registry
|
||||
from .choices import DataSourceTypeChoices
|
||||
@ -91,7 +91,7 @@ class GitBackend(DataBackend):
|
||||
def fetch(self):
|
||||
local_path = tempfile.TemporaryDirectory()
|
||||
|
||||
config = StackedConfig.default()
|
||||
config = ConfigDict()
|
||||
clone_args = {
|
||||
"branch": self.params.get('branch'),
|
||||
"config": config,
|
||||
|
@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.rqworker import get_queue_for_model
|
||||
from utilities.rqworker import get_queue_for_model, get_rq_retry
|
||||
|
||||
__all__ = (
|
||||
'Job',
|
||||
@ -219,5 +219,6 @@ class Job(models.Model):
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
username=self.user.username
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
@ -1,12 +1,12 @@
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
@ -14,7 +14,6 @@ from dcim import filtersets
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.api.nested_serializers import NestedConfigTemplateSerializer
|
||||
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
|
||||
# Devices/modules
|
||||
#
|
||||
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
|
||||
class DeviceViewSet(
|
||||
SequentialBulkCreatesMixin,
|
||||
ConfigContextQuerySetMixin,
|
||||
ConfigTemplateRenderMixin,
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
|
||||
@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
|
||||
'vdcs',
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
|
@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100GE_CFP = '100gbase-x-cfp'
|
||||
TYPE_100GE_CFP2 = '100gbase-x-cfp2'
|
||||
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
|
||||
TYPE_100GE_CXP = '100gbase-x-cxp'
|
||||
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
||||
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
TYPE_800GE_OSFP = '800gbase-x-osfp'
|
||||
|
||||
@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
|
||||
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
|
||||
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
|
||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
|
||||
)
|
||||
@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_LSH_PC = 'lsh-pc'
|
||||
TYPE_LSH_UPC = 'lsh-upc'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_LX5 = 'lx5'
|
||||
TYPE_LX5_PC = 'lx5-pc'
|
||||
TYPE_LX5_UPC = 'lx5-upc'
|
||||
TYPE_LX5_APC = 'lx5-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
TYPE_SN = 'sn'
|
||||
@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_LSH_PC, 'LSH/PC'),
|
||||
(TYPE_LSH_UPC, 'LSH/UPC'),
|
||||
(TYPE_LSH_APC, 'LSH/APC'),
|
||||
(TYPE_LX5, 'LX.5'),
|
||||
(TYPE_LX5_PC, 'LX.5/PC'),
|
||||
(TYPE_LX5_UPC, 'LX.5/UPC'),
|
||||
(TYPE_LX5_APC, 'LX.5/APC'),
|
||||
(TYPE_MPO, 'MPO'),
|
||||
(TYPE_MTRJ, 'MTRJ'),
|
||||
(TYPE_SC, 'SC'),
|
||||
|
@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label=_('Device (name)'),
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_type',
|
||||
queryset=DeviceType.objects.all(),
|
||||
label=_('Device type (ID)'),
|
||||
)
|
||||
device_type = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_type__model',
|
||||
queryset=DeviceType.objects.all(),
|
||||
to_field_name='model',
|
||||
label=_('Device type (model)'),
|
||||
)
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_role',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label=_('Device role (ID)'),
|
||||
)
|
||||
device_role = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Device role (slug)'),
|
||||
)
|
||||
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
|
@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
from timezone_field import TimeZoneFormField
|
||||
@ -13,6 +14,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
|
||||
__all__ = (
|
||||
'CableBulkEditForm',
|
||||
@ -1139,7 +1141,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
||||
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power',
|
||||
'tx_power', 'wireless_lans'
|
||||
]),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
@ -1229,6 +1231,19 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
wireless_lan_group = DynamicModelChoiceField(
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LAN group')
|
||||
)
|
||||
wireless_lans = DynamicModelMultipleChoiceField(
|
||||
queryset=WirelessLAN.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LANs'),
|
||||
query_params={
|
||||
'group_id': '$wireless_lan_group',
|
||||
}
|
||||
)
|
||||
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@ -1238,12 +1253,14 @@ class InterfaceBulkEditForm(
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
('Wireless', (
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -1276,8 +1293,13 @@ class InterfaceBulkEditForm(
|
||||
break
|
||||
|
||||
if site is not None:
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
# Query for VLANs assigned to the same site and VLANs with no site assigned (null).
|
||||
self.fields['untagged_vlan'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
self.fields['tagged_vlans'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
|
||||
self.fields['parent'].choices = ()
|
||||
self.fields['parent'].widget.attrs['disabled'] = True
|
||||
|
@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('The default platform for devices of this type (optional)')
|
||||
)
|
||||
weight = forms.DecimalField(
|
||||
required=False,
|
||||
help_text=_('Device weight'),
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for device weight')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'description', 'comments',
|
||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
|
||||
]
|
||||
|
||||
|
||||
@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
weight = forms.DecimalField(
|
||||
required=False,
|
||||
help_text=_('Module weight'),
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for module weight')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
|
||||
|
||||
|
||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
@ -1060,6 +1078,10 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
if device.virtual_chassis and device.virtual_chassis.master == device and \
|
||||
model.objects.filter(device=device, name=name).count() == 0:
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
|
@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Virtual Chassis')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id'
|
||||
'virtual_chassis_id': '$virtual_chassis_id',
|
||||
'device_type_id': '$device_type_id',
|
||||
'role_id': '$device_role_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
|
||||
'device_id', 'vdc_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'position')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
installed_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Child Device'),
|
||||
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
|
||||
help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
|
||||
)
|
||||
|
||||
def __init__(self, device_bay, *args, **kwargs):
|
||||
|
@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
choices=[],
|
||||
label=_('Rear ports'),
|
||||
help_text=_('Select one rear port assignment for each front port being created.'),
|
||||
widget=forms.SelectMultiple(attrs={'size': 6})
|
||||
)
|
||||
|
||||
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
|
||||
@ -242,6 +243,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
choices=[],
|
||||
label=_('Rear ports'),
|
||||
help_text=_('Select one rear port assignment for each front port being created.'),
|
||||
widget=forms.SelectMultiple(attrs={'size': 6})
|
||||
)
|
||||
|
||||
# Override fieldsets from FrontPortForm to omit rear_port_position
|
||||
|
42
netbox/dcim/migrations/0172_larger_power_draw_values.py
Normal file
42
netbox/dcim/migrations/0172_larger_power_draw_values.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-12 18:46
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0171_cabletermination_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
]
|
@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
choices=PowerPortTypeChoices,
|
||||
blank=True
|
||||
)
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
maximum_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Maximum power draw (watts)")
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
allocated_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
|
@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
blank=True,
|
||||
help_text=_('Physical port type')
|
||||
)
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
maximum_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Maximum power draw (watts)")
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
allocated_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
|
@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
|
@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
powerport.get_power_draw()['allocated'] for powerport in powerports
|
||||
])
|
||||
|
||||
return int(allocated_draw / available_power_total * 100)
|
||||
return round(allocated_draw / available_power_total * 100, 1)
|
||||
|
||||
@cached_property
|
||||
def total_weight(self):
|
||||
|
@ -22,6 +22,11 @@ __all__ = (
|
||||
'RackElevationSVG',
|
||||
)
|
||||
|
||||
GRADIENT_RESERVED = '#b0b0ff'
|
||||
GRADIENT_OCCUPIED = '#d7d7d7'
|
||||
GRADIENT_BLOCKED = '#ffc0c0'
|
||||
STROKE_RESERVED = '#4d4dff'
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
@ -37,15 +42,28 @@ def get_device_name(device):
|
||||
|
||||
|
||||
def get_device_description(device):
|
||||
return '{} ({}) — {} {} ({}U) {} {}'.format(
|
||||
device.name,
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
floatformat(device.device_type.u_height),
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
"""
|
||||
Return a description for a device to be rendered in the rack elevation in the following format
|
||||
|
||||
Name: <name>
|
||||
Role: <device_role>
|
||||
Device Type: <manufacturer> <model> (<u_height>)
|
||||
Asset tag: <asset_tag> (if defined)
|
||||
Serial: <serial> (if defined)
|
||||
Description: <description> (if defined)
|
||||
"""
|
||||
description = f'Name: {device.name}'
|
||||
description += f'\nRole: {device.device_role}'
|
||||
u_height = f'{floatformat(device.device_type.u_height)}U'
|
||||
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
|
||||
if device.asset_tag:
|
||||
description += f'\nAsset tag: {device.asset_tag}'
|
||||
if device.serial:
|
||||
description += f'\nSerial: {device.serial}'
|
||||
if device.description:
|
||||
description += f'\nDescription: {device.description}'
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
@ -119,9 +137,9 @@ class RackElevationSVG:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# Add gradients
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
|
||||
|
||||
return drawing
|
||||
|
||||
@ -233,13 +251,13 @@ class RackElevationSVG:
|
||||
coords = self._get_device_coords(segment[0], u_height)
|
||||
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
|
||||
size = (
|
||||
self.margin_width,
|
||||
self.margin_width - 3,
|
||||
u_height * self.unit_height
|
||||
)
|
||||
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
|
||||
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
|
||||
link.add(
|
||||
Rect(coords, size, class_='reservation')
|
||||
Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
|
||||
)
|
||||
self.drawing.add(link)
|
||||
|
||||
|
@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
config_template = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
parent_device = tables.Column(
|
||||
verbose_name='Parent Device',
|
||||
linkify=True,
|
||||
accessor='parent_bay__device'
|
||||
)
|
||||
device_bay_position = tables.Column(
|
||||
verbose_name='Position (Device Bay)',
|
||||
accessor='parent_bay',
|
||||
linkify=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
model = models.Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
|
||||
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
|
||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
|
@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_rack_fit(self):
|
||||
"""
|
||||
Check that creating multiple devices with overlapping position fails.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
device_type = DeviceType.objects.all()[1]
|
||||
data = [
|
||||
{
|
||||
'device_type': device_type.pk,
|
||||
'device_role': device.device_role.pk,
|
||||
'site': device.site.pk,
|
||||
'name': 'Test Device 7',
|
||||
'rack': device.rack.pk,
|
||||
'face': 'front',
|
||||
'position': 1
|
||||
},
|
||||
{
|
||||
'device_type': device_type.pk,
|
||||
'device_role': device.device_role.pk,
|
||||
'site': device.site.pk,
|
||||
'name': 'Test Device 8',
|
||||
'rack': device.rack.pk,
|
||||
'face': 'front',
|
||||
'position': 2
|
||||
}
|
||||
]
|
||||
|
||||
self.add_permissions('dcim.add_device')
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Module
|
||||
|
@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
|
||||
|
||||
class DeviceComponentFilterSetTests:
|
||||
|
||||
def test_device_type(self):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device_type': [device_types[0].model, device_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device_role(self):
|
||||
device_role = DeviceRole.objects.all()[:2]
|
||||
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Region.objects.all()
|
||||
filterset = RegionFilterSet
|
||||
@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ConsolePort.objects.all()
|
||||
filterset = ConsolePortFilterSet
|
||||
|
||||
@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
filterset = ConsoleServerPortFilterSet
|
||||
|
||||
@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerPort.objects.all()
|
||||
filterset = PowerPortFilterSet
|
||||
|
||||
@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
filterset = PowerOutletFilterSet
|
||||
|
||||
@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = Interface.objects.all()
|
||||
filterset = InterfaceFilterSet
|
||||
|
||||
@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = FrontPort.objects.all()
|
||||
filterset = FrontPortFilterSet
|
||||
|
||||
@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = RearPort.objects.all()
|
||||
filterset = RearPortFilterSet
|
||||
|
||||
@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ModuleBay.objects.all()
|
||||
filterset = ModuleBayFilterSet
|
||||
|
||||
@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = DeviceBay.objects.all()
|
||||
filterset = DeviceBayFilterSet
|
||||
|
||||
@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'rack': [racks[0].name, racks[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device_type(self):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device_type': [device_types[0].model, device_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device_role(self):
|
||||
device_role = DeviceRole.objects.all()[:2]
|
||||
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
|
@ -681,11 +681,15 @@ class DeviceTypeTestCase(
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
manufacturer: Generic
|
||||
default_platform: Platform
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
default_platform: Platform
|
||||
u_height: 2
|
||||
is_full_depth: false
|
||||
airflow: front-to-rear
|
||||
subdevice_role: parent
|
||||
weight: 10
|
||||
weight_unit: kg
|
||||
comments: Test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@ -794,8 +798,16 @@ inventory-items:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
device_type = DeviceType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(device_type.comments, 'Test comment')
|
||||
self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
|
||||
self.assertEqual(device_type.default_platform.pk, platform.pk)
|
||||
self.assertEqual(device_type.slug, 'test-1000')
|
||||
self.assertEqual(device_type.u_height, 2)
|
||||
self.assertFalse(device_type.is_full_depth)
|
||||
self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
|
||||
self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
|
||||
self.assertEqual(device_type.weight, 10)
|
||||
self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
|
||||
self.assertEqual(device_type.comments, 'Test comment')
|
||||
|
||||
# Verify all of the components were created
|
||||
self.assertEqual(device_type.consoleporttemplates.count(), 3)
|
||||
@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
|
||||
IMPORT_DATA = """
|
||||
manufacturer: Generic
|
||||
model: TEST-1000
|
||||
weight: 10
|
||||
weight_unit: lb
|
||||
comments: Test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@ -1082,7 +1096,8 @@ front-ports:
|
||||
"""
|
||||
|
||||
# Create the manufacturer
|
||||
Manufacturer(name='Generic', slug='generic').save()
|
||||
manufacturer = Manufacturer(name='Generic', slug='generic')
|
||||
manufacturer.save()
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions(
|
||||
@ -1105,6 +1120,9 @@ front-ports:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
module_type = ModuleType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
|
||||
self.assertEqual(module_type.weight, 10)
|
||||
self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
|
||||
self.assertEqual(module_type.comments, 'Test comment')
|
||||
|
||||
# Verify all the components were created
|
||||
@ -2889,6 +2907,7 @@ class CableTestCase(
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
vc = VirtualChassis.objects.create(name='Virtual Chassis')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
|
||||
@ -2898,6 +2917,10 @@ class CableTestCase(
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
vc.members.set((devices[0], devices[1], devices[2]))
|
||||
vc.master = devices[0]
|
||||
vc.save()
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
@ -2911,6 +2934,10 @@ class CableTestCase(
|
||||
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@ -2943,6 +2970,8 @@ class CableTestCase(
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
|
||||
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.RegionTable
|
||||
|
||||
|
||||
@register_model_view(Region, 'contacts')
|
||||
class RegionContactsView(ObjectContactsView):
|
||||
queryset = Region.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Site groups
|
||||
#
|
||||
@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SiteGroupTable
|
||||
|
||||
|
||||
@register_model_view(SiteGroup, 'contacts')
|
||||
class SiteGroupContactsView(ObjectContactsView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SiteTable
|
||||
|
||||
|
||||
@register_model_view(Site, 'contacts')
|
||||
class SiteContactsView(ObjectContactsView):
|
||||
queryset = Site.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Locations
|
||||
#
|
||||
@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.LocationTable
|
||||
|
||||
|
||||
@register_model_view(Location, 'contacts')
|
||||
class LocationContactsView(ObjectContactsView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.RackTable
|
||||
|
||||
|
||||
@register_model_view(Rack, 'contacts')
|
||||
class RackContactsView(ObjectContactsView):
|
||||
queryset = Rack.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@register_model_view(Manufacturer, 'contacts')
|
||||
class ManufacturerContactsView(ObjectContactsView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
@register_model_view(Device, 'contacts')
|
||||
class DeviceContactsView(ObjectContactsView):
|
||||
queryset = Device.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.PowerPanelTable
|
||||
|
||||
|
||||
@register_model_view(PowerPanel, 'contacts')
|
||||
class PowerPanelContactsView(ObjectContactsView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
'fields': ('ALLOWED_URL_SCHEMES',),
|
||||
}),
|
||||
('Banners', {
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('Pagination', {
|
||||
|
@ -187,11 +187,10 @@ class ReportViewSet(ViewSet):
|
||||
"""
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=report_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@ -202,7 +201,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each report (if any)
|
||||
for report in report_list:
|
||||
report.result = results.get(report.full_name, None)
|
||||
report.result = results.get(report.name, None)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
'request': request,
|
||||
@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet):
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=script_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.full_name, None)
|
||||
script.result = results.get(script.name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
|
@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
|
||||
VISIBILITY_READ_WRITE = 'read-write'
|
||||
VISIBILITY_READ_ONLY = 'read-only'
|
||||
VISIBILITY_HIDDEN = 'hidden'
|
||||
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
|
||||
|
||||
CHOICES = (
|
||||
(VISIBILITY_READ_WRITE, 'Read/Write'),
|
||||
(VISIBILITY_READ_ONLY, 'Read-only'),
|
||||
(VISIBILITY_HIDDEN, 'Hidden'),
|
||||
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -65,8 +65,14 @@ class Condition:
|
||||
"""
|
||||
Evaluate the provided data to determine whether it matches the condition.
|
||||
"""
|
||||
def _get(obj, key):
|
||||
if isinstance(obj, list):
|
||||
return [dict.get(i, key) for i in obj]
|
||||
|
||||
return dict.get(obj, key)
|
||||
|
||||
try:
|
||||
value = functools.reduce(dict.get, self.attr.split('.'), data)
|
||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||
except TypeError:
|
||||
# Invalid key path
|
||||
value = None
|
||||
|
@ -10,8 +10,9 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.utils import FeatureQuery
|
||||
@ -35,7 +36,8 @@ def get_content_type_labels():
|
||||
return [
|
||||
(content_type_identifier(ct), content_type_name(ct))
|
||||
for ct in ContentType.objects.filter(
|
||||
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange')
|
||||
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
|
||||
Q(app_label='extras', model='configcontext')
|
||||
).order_by('app_label', 'model')
|
||||
]
|
||||
|
||||
@ -148,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
filters = forms.JSONField(
|
||||
required=False,
|
||||
label='Object filters',
|
||||
help_text=_("Only objects matching the specified filters will be counted")
|
||||
help_text=_("Filters to apply when counting the number of objects")
|
||||
)
|
||||
|
||||
def clean_filters(self):
|
||||
@ -157,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
dict(data)
|
||||
except TypeError:
|
||||
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
|
||||
for model in get_models_from_content_types(self.cleaned_data.get('models')):
|
||||
try:
|
||||
# Validate the filters by creating a QuerySet
|
||||
model.objects.filter(**data).none()
|
||||
except Exception:
|
||||
model_name = model._meta.verbose_name_plural
|
||||
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
@ -171,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
for model in get_models_from_content_types(self.config['models']):
|
||||
permission = get_permission_for_model(model, 'view')
|
||||
if request.user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, 'list'))
|
||||
qs = model.objects.restrict(request.user, 'view')
|
||||
# Apply any specified filters
|
||||
if filters := self.config.get('filters'):
|
||||
qs = qs.filter(**filters)
|
||||
params = QueryDict(mutable=True)
|
||||
params.update(filters)
|
||||
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
|
||||
qs = filterset(params, qs).qs
|
||||
url = f'{url}?{params.urlencode()}'
|
||||
object_count = qs.count
|
||||
counts.append((model, object_count))
|
||||
counts.append((model, object_count, url))
|
||||
else:
|
||||
counts.append((model, None))
|
||||
counts.append((model, None, None))
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'counts': counts,
|
||||
|
@ -4,9 +4,10 @@ from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
|
||||
|
||||
@ -15,6 +16,7 @@ __all__ = (
|
||||
'CustomFieldImportForm',
|
||||
'CustomLinkImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'SavedFilterImportForm',
|
||||
'TagImportForm',
|
||||
'WebhookImportForm',
|
||||
@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm):
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
assigned_object_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
label=_('Assigned object type'),
|
||||
)
|
||||
kind = CSVChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
help_text=_('The classification of entry')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = (
|
||||
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
|
||||
)
|
||||
|
@ -11,7 +11,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .mixins import SavedFiltersMixin
|
||||
@ -22,6 +22,7 @@ __all__ = (
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
'ImageAttachmentFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
'LocalConfigContextFilterForm',
|
||||
'ObjectChangeFilterForm',
|
||||
@ -137,6 +138,20 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('content_type_id', 'name',)),
|
||||
)
|
||||
content_type_id = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
required=False
|
||||
)
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
|
@ -7,12 +7,14 @@ class Empty(Lookup):
|
||||
Filter on whether a string is empty.
|
||||
"""
|
||||
lookup_name = 'empty'
|
||||
prepare_rhs = False
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
||||
def as_sql(self, compiler, connection):
|
||||
sql, params = compiler.compile(self.lhs)
|
||||
if self.rhs:
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
|
||||
else:
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
|
@ -111,7 +111,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
instance=module,
|
||||
object=module,
|
||||
name=script.name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
|
@ -13,6 +13,22 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
|
||||
field=models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
flags=re.RegexFlag['IGNORECASE'],
|
||||
message='Only alphanumeric characters and underscores are allowed.',
|
||||
regex='^[a-z0-9_]+$',
|
||||
),
|
||||
django.core.validators.RegexValidator(
|
||||
flags=re.RegexFlag['IGNORECASE'],
|
||||
inverse_match=True,
|
||||
message='Double underscores are not permitted in custom field names.',
|
||||
regex=r'__',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
message="Only alphanumeric characters and underscores are allowed.",
|
||||
flags=re.IGNORECASE
|
||||
),
|
||||
RegexValidator(
|
||||
regex=r'__',
|
||||
message="Double underscores are not permitted in custom field names.",
|
||||
flags=re.IGNORECASE,
|
||||
inverse_match=True
|
||||
),
|
||||
)
|
||||
)
|
||||
label = models.CharField(
|
||||
|
@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
:param context: The context passed to Jinja2
|
||||
"""
|
||||
text = render_jinja2(self.link_text, context)
|
||||
text = render_jinja2(self.link_text, context).strip()
|
||||
if not text:
|
||||
return {}
|
||||
link = render_jinja2(self.link_url, context)
|
||||
link = render_jinja2(self.link_url, context).strip()
|
||||
link_target = ' target="_blank"' if self.new_window else ''
|
||||
|
||||
# Sanitize link text
|
||||
|
@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
@ -16,6 +17,8 @@ __all__ = (
|
||||
'ScriptModule',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
class Script(WebhooksMixin, models.Model):
|
||||
"""
|
||||
@ -53,7 +56,12 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
# For child objects in submodules use the full import path w/o the root module as the name
|
||||
return cls.full_name.split(".", maxsplit=1)[1]
|
||||
|
||||
try:
|
||||
module = self.get_module()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
||||
module = None
|
||||
|
||||
scripts = {}
|
||||
ordered = getattr(module, 'script_order', [])
|
||||
|
||||
|
@ -13,6 +13,7 @@ __all__ = (
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'SavedFilterTable',
|
||||
@ -21,6 +22,14 @@ __all__ = (
|
||||
'WebhookTable',
|
||||
)
|
||||
|
||||
IMAGEATTACHMENT_IMAGE = '''
|
||||
{% if record.image %}
|
||||
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
'''
|
||||
|
||||
|
||||
class CustomFieldTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
@ -72,6 +81,7 @@ class ExportTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
|
||||
@ -86,6 +96,31 @@ class ExportTemplateTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentTable(NetBoxTable):
|
||||
id = tables.Column(
|
||||
linkify=False
|
||||
)
|
||||
content_type = columns.ContentTypeColumn()
|
||||
parent = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
image = tables.TemplateColumn(
|
||||
template_code=IMAGEATTACHMENT_IMAGE,
|
||||
)
|
||||
size = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Size (bytes)'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ImageAttachment
|
||||
fields = (
|
||||
'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
|
||||
|
||||
|
||||
class SavedFilterTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
@ -195,6 +230,7 @@ class ConfigContextTable(NetBoxTable):
|
||||
verbose_name='Active'
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
|
||||
@ -219,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
|
||||
|
||||
cls.object_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
def test_invalid_name(self):
|
||||
"""
|
||||
Try creating a CustomField with an invalid name.
|
||||
"""
|
||||
with self.assertRaises(ValidationError):
|
||||
# Invalid character
|
||||
CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
# Double underscores not permitted
|
||||
CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
|
||||
|
||||
def test_text_field(self):
|
||||
value = 'Foobar!'
|
||||
|
||||
|
@ -73,6 +73,7 @@ urlpatterns = [
|
||||
path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/', views.ImageAttachmentListView.as_view(), name='imageattachment_list'),
|
||||
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
|
||||
path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
|
||||
|
||||
@ -81,6 +82,7 @@ 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/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||
|
||||
# Change logging
|
||||
|
@ -577,6 +577,14 @@ class ObjectChangeView(generic.ObjectView):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentListView(generic.ObjectListView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
filterset = filtersets.ImageAttachmentFilterSet
|
||||
filterset_form = forms.ImageAttachmentFilterForm
|
||||
table = tables.ImageAttachmentTable
|
||||
actions = ('export',)
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'edit')
|
||||
class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
@ -617,7 +625,7 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(JournalEntry)
|
||||
@ -666,6 +674,11 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
class JournalEntryBulkImportView(generic.BulkImportView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
model_form = forms.JournalEntryImportForm
|
||||
|
||||
|
||||
#
|
||||
# Dashboard & widgets
|
||||
#
|
||||
@ -1033,7 +1046,6 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
print(module)
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
@ -9,6 +9,7 @@ from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.registry import registry
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.rqworker import get_rq_retry
|
||||
from utilities.utils import serialize_object
|
||||
from .choices import *
|
||||
from .models import Webhook
|
||||
@ -116,5 +117,6 @@ def flush_webhooks(queue):
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=str(timezone.now()),
|
||||
username=data['username'],
|
||||
request_id=data['request_id']
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
@ -262,38 +262,21 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'interfaces': '$interface'
|
||||
}
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'interfaces': '$vminterface'
|
||||
}
|
||||
selector=True,
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Interface'),
|
||||
query_params={
|
||||
'virtual_machine_id': '$virtual_machine'
|
||||
}
|
||||
)
|
||||
fhrpgroup = DynamicModelChoiceField(
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('FHRP Group')
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
@ -301,33 +284,11 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Device')
|
||||
)
|
||||
nat_virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
nat_vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_inside = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('IP Address'),
|
||||
query_params={
|
||||
'device_id': '$nat_device',
|
||||
'virtual_machine_id': '$nat_virtual_machine',
|
||||
'vrf_id': '$nat_vrf',
|
||||
}
|
||||
)
|
||||
primary_for_parent = forms.BooleanField(
|
||||
required=False,
|
||||
@ -338,8 +299,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine',
|
||||
'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -354,17 +315,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is FHRPGroup:
|
||||
initial['fhrpgroup'] = instance.assigned_object
|
||||
if instance.nat_inside:
|
||||
nat_inside_parent = instance.nat_inside.assigned_object
|
||||
if type(nat_inside_parent) is Interface:
|
||||
initial['nat_site'] = nat_inside_parent.device.site.pk
|
||||
if nat_inside_parent.device.rack:
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
if cluster := nat_inside_parent.virtual_machine.cluster:
|
||||
initial['nat_cluster'] = cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -378,6 +328,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
):
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
# Disable object assignment fields if the IP address is designated as primary
|
||||
if self.initial.get('primary_for_parent'):
|
||||
self.fields['interface'].disabled = True
|
||||
self.fields['vminterface'].disabled = True
|
||||
self.fields['fhrpgroup'].disabled = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@ -390,7 +346,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
selected_objects[1]: "An IP address can only be assigned to a single object."
|
||||
})
|
||||
elif selected_objects:
|
||||
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
raise ValidationError(
|
||||
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
||||
)
|
||||
self.instance.assigned_object = assigned_object
|
||||
else:
|
||||
self.instance.assigned_object = None
|
||||
|
||||
@ -401,6 +362,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
)
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if interface and (address := self.cleaned_data.get('address')):
|
||||
if address.ip == address.network:
|
||||
msg = f"{address} is a network ID, which may not be assigned to an interface."
|
||||
if address.version == 4 and address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
|
||||
raise ValidationError(msg)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
|
@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
|
||||
if available_ips:
|
||||
return next(iter(available_ips))
|
||||
|
||||
def get_related_ips(self):
|
||||
"""
|
||||
Return all IPAddresses belonging to the same VRF.
|
||||
"""
|
||||
return IPAddress.objects.exclude(address=str(self.address)).filter(
|
||||
vrf=self.vrf, address__net_contained_or_equal=str(self.address)
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
@ -10,11 +10,13 @@ from circuits.models import Provider
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import PrefixStatusChoices
|
||||
from .constants import *
|
||||
from .models import *
|
||||
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
|
||||
@ -496,7 +498,7 @@ class PrefixView(generic.ObjectView):
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True)
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
|
||||
).filter(
|
||||
prefix__net_contains=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
@ -755,19 +757,9 @@ class IPAddressView(generic.ObjectView):
|
||||
# Limit to a maximum of 10 duplicates displayed here
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
|
||||
address=str(instance.address)
|
||||
).filter(
|
||||
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
|
||||
)
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
related_ips_table.configure(request)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'related_ips_table': related_ips_table,
|
||||
}
|
||||
|
||||
|
||||
@ -872,6 +864,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.IPAddressTable
|
||||
|
||||
|
||||
@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses')
|
||||
class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
||||
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
|
||||
child_model = IPAddress
|
||||
table = tables.IPAddressTable
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
template_name = 'ipam/ipaddress/ip_addresses.html'
|
||||
tab = ViewTab(
|
||||
label=_('Related IPs'),
|
||||
badge=lambda x: x.get_related_ips().count(),
|
||||
weight=500,
|
||||
hide_if_empty=True,
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_related_ips().restrict(request.user, 'view')
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
@ -1298,6 +1308,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.L2VPNTable
|
||||
|
||||
|
||||
@register_model_view(L2VPN, 'contacts')
|
||||
class L2VPNContactsView(ObjectContactsView):
|
||||
queryset = L2VPN.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# L2VPN terminations
|
||||
#
|
||||
|
@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'BriefModeMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'BulkUpdateModelMixin',
|
||||
'CustomFieldsMixin',
|
||||
'ExportTemplatesMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'ObjectValidationMixin',
|
||||
'SequentialBulkCreatesMixin',
|
||||
)
|
||||
|
||||
|
||||
@ -94,6 +95,30 @@ class ExportTemplatesMixin:
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class SequentialBulkCreatesMixin:
|
||||
"""
|
||||
Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
|
||||
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
|
||||
appropriately.
|
||||
"""
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not isinstance(request.data, list):
|
||||
# Creating a single object
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
return_data = []
|
||||
for data in request.data:
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return_data.append(serializer.data)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
|
||||
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class BulkUpdateModelMixin:
|
||||
"""
|
||||
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
||||
|
@ -156,6 +156,9 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
try:
|
||||
group_list.append(Group.objects.get(name=name))
|
||||
except Group.DoesNotExist:
|
||||
if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
|
||||
group_list.append(Group.objects.create(name=name))
|
||||
else:
|
||||
logging.error(
|
||||
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
|
@ -28,6 +28,17 @@ PARAMS = (
|
||||
),
|
||||
},
|
||||
),
|
||||
ConfigParam(
|
||||
name='BANNER_MAINTENANCE',
|
||||
label=_('Maintenance banner'),
|
||||
default='NetBox is currently in maintenance mode. Functionality may be limited.',
|
||||
description=_('Additional content to display when in maintenance mode'),
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
ConfigParam(
|
||||
name='BANNER_TOP',
|
||||
label=_('Top banner'),
|
||||
|
@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
# create the new filter with the same type because there is no guarantee the defined type
|
||||
# is the same as the default type for the field
|
||||
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
|
||||
new_filter = type(existing_filter)(
|
||||
filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
|
||||
new_filter = filter_cls(
|
||||
field_name=field_name,
|
||||
lookup_expr=lookup_expr,
|
||||
label=existing_filter.label,
|
||||
@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
def filter_for_lookup(cls, field, lookup_type):
|
||||
|
||||
if lookup_type == 'empty':
|
||||
return django_filters.BooleanFilter, {}
|
||||
|
||||
return super().filter_for_lookup(field, lookup_type)
|
||||
|
||||
|
||||
class ChangeLoggedModelFilterSet(BaseFilterSet):
|
||||
"""
|
||||
|
@ -3,19 +3,21 @@ import uuid
|
||||
from urllib import parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.contrib import auth, messages
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import ProgrammingError
|
||||
from django.db import connection, ProgrammingError
|
||||
from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from extras.context_managers import change_logging
|
||||
from netbox.config import clear_config
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request, rest_api_server_error
|
||||
|
||||
__all__ = (
|
||||
'CoreMiddleware',
|
||||
'MaintenanceModeMiddleware',
|
||||
'RemoteUserMiddleware',
|
||||
)
|
||||
|
||||
@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
|
||||
groups = []
|
||||
logger.debug(f"Groups are {groups}")
|
||||
return groups
|
||||
|
||||
|
||||
class MaintenanceModeMiddleware:
|
||||
"""
|
||||
Middleware that checks if the application is in maintenance mode
|
||||
and restricts write-related operations to the database.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if get_config().MAINTENANCE_MODE:
|
||||
self._set_session_type(
|
||||
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
|
||||
)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
@staticmethod
|
||||
def _set_session_type(allow_write):
|
||||
"""
|
||||
Prevent any write-related database operations.
|
||||
|
||||
Args:
|
||||
allow_write (bool): If True, write operations will be permitted.
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
mode = 'READ WRITE' if allow_write else 'READ ONLY'
|
||||
cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
"""
|
||||
Prevent any write-related database operations if an exception is raised.
|
||||
"""
|
||||
if isinstance(exception, InternalError):
|
||||
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
|
||||
'operations. Please try again later.'
|
||||
|
||||
if is_api_request(request):
|
||||
return rest_api_server_error(request, error=error_message)
|
||||
|
||||
messages.error(request, error_message)
|
||||
return HttpResponseRedirect(request.path_info)
|
||||
|
@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
|
||||
data = {}
|
||||
|
||||
for field in CustomField.objects.get_for_model(self):
|
||||
value = self.custom_field_data.get(field.name)
|
||||
|
||||
# Skip fields that are hidden if 'omit_hidden' is set
|
||||
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
if omit_hidden:
|
||||
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
|
||||
continue
|
||||
|
||||
value = self.custom_field_data.get(field.name)
|
||||
data[field] = field.deserialize(value)
|
||||
|
||||
return data
|
||||
@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
for cf in visible_custom_fields:
|
||||
value = self.custom_field_data.get(cf.name)
|
||||
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
|
||||
continue
|
||||
value = cf.deserialize(value)
|
||||
groups[cf.group_name][cf] = value
|
||||
|
||||
|
@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
|
||||
label=_('Connections'),
|
||||
items=(
|
||||
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
|
||||
MenuItem(
|
||||
link='dcim:interface_connections_list',
|
||||
link_text=_('Interface Connections'),
|
||||
@ -292,6 +292,7 @@ CUSTOMIZATION_MENU = Menu(
|
||||
get_model_item('extras', 'exporttemplate', _('Export Templates')),
|
||||
get_model_item('extras', 'savedfilter', _('Saved Filters')),
|
||||
get_model_item('extras', 'tag', 'Tags'),
|
||||
get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
@ -336,7 +337,7 @@ OPERATIONS_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Logging'),
|
||||
items=(
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]),
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
||||
get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
|
||||
),
|
||||
),
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.5.1-dev'
|
||||
VERSION = '3.5.4-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
|
||||
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
|
||||
REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
|
||||
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
|
||||
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
|
||||
@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
|
||||
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
|
||||
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
||||
@ -382,6 +385,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'netbox.middleware.RemoteUserMiddleware',
|
||||
'netbox.middleware.CoreMiddleware',
|
||||
'netbox.middleware.MaintenanceModeMiddleware',
|
||||
'django_prometheus.middleware.PrometheusAfterMiddleware',
|
||||
]
|
||||
|
||||
@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
|
||||
MAINTENANCE_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}admin/',
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
'json': 'utilities.serializers.json',
|
||||
}
|
||||
|
@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
|
||||
return ''
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||
if request := getattr(table, 'context', {}).get('request'):
|
||||
return_url = request.GET.get('return_url', request.get_full_path())
|
||||
url_appendix = f'?return_url={quote(return_url)}'
|
||||
else:
|
||||
url_appendix = ''
|
||||
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
|
@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
list(new_user.groups.all())
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_GROUPS=True,
|
||||
LOGIN_REQUIRED=True,
|
||||
)
|
||||
def test_remote_auth_remote_groups_autocreate(self):
|
||||
"""
|
||||
Test enabling remote authentication with group sync and autocreate
|
||||
enabled with the default configuration.
|
||||
"""
|
||||
headers = {
|
||||
"HTTP_REMOTE_USER": "remoteuser2",
|
||||
"HTTP_REMOTE_USER_GROUP": "Group 1|Group 2",
|
||||
}
|
||||
|
||||
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS)
|
||||
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
|
||||
self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER")
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP")
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|")
|
||||
|
||||
groups = (
|
||||
Group(name="Group 1"),
|
||||
Group(name="Group 2"),
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("home"), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
new_user = User.objects.get(username="remoteuser2")
|
||||
self.assertEqual(
|
||||
int(self.client.session.get("_auth_user_id")),
|
||||
new_user.pk,
|
||||
msg="Authentication failed",
|
||||
)
|
||||
self.assertListEqual(
|
||||
[group.name for group in groups],
|
||||
[group.name for group in list(new_user.groups.all())],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -30,6 +30,7 @@
|
||||
"dayjs": "^1.11.5",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "^7.2.3",
|
||||
"html-entities": "^2.3.3",
|
||||
"htmx.org": "^1.8.0",
|
||||
"just-debounce-it": "^3.1.1",
|
||||
"query-string": "^7.1.1",
|
||||
|
@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
|
||||
import { initButtons } from './buttons';
|
||||
import { initSelect } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
import { initBootstrap } from './bs';
|
||||
|
||||
function initDepedencies(): void {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector]) {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
|
||||
init();
|
||||
}
|
||||
}
|
||||
@ -22,4 +23,8 @@ export function initHtmx(): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of getElements('[hx-trigger=load]')) {
|
||||
element.addEventListener('htmx:afterSettle', initDepedencies);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { readableColor } from 'color2k';
|
||||
import debounce from 'just-debounce-it';
|
||||
import { encode } from 'html-entities';
|
||||
import queryString from 'query-string';
|
||||
import SlimSelect from 'slim-select';
|
||||
import { createToast } from '../../bs';
|
||||
@ -446,7 +447,7 @@ export class APISelect {
|
||||
// Build SlimSelect options from all already-selected options.
|
||||
const preSelectedOptions = preSelected.map(option => ({
|
||||
value: option.value,
|
||||
text: option.innerText,
|
||||
text: encode(option.innerText),
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})) as Option[];
|
||||
@ -454,7 +455,7 @@ export class APISelect {
|
||||
let options = [] as Option[];
|
||||
|
||||
for (const result of data.results) {
|
||||
let text = result.display;
|
||||
let text = encode(result.display);
|
||||
|
||||
if (typeof result._depth === 'number' && result._depth > 0) {
|
||||
// If the object has a `_depth` property, indent its display text.
|
||||
|
@ -231,6 +231,10 @@ table {
|
||||
|
||||
p {
|
||||
// Remove spacing from paragraph elements within tables.
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
@ -1818,6 +1818,11 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
html-entities@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
|
||||
integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
|
||||
|
||||
htmx.org@^1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"
|
||||
|
@ -77,10 +77,10 @@ Blocks:
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config.MAINTENANCE_MODE %}
|
||||
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
|
||||
<div class="alert alert-warning text-center mx-3" role="alert">
|
||||
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
|
||||
NetBox is currently in maintenance mode. Functionality may be limited.
|
||||
{{ config.BANNER_MAINTENANCE|escape }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -70,7 +70,6 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
|
@ -132,9 +132,16 @@
|
||||
</tr>
|
||||
{% for field, value in fields.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||
</td>
|
||||
<th scope="row">{{ field }}
|
||||
{% if field.description %}
|
||||
<i
|
||||
class="mdi mdi-information text-primary"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="{{ field.description|escape }}"
|
||||
></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
<td>
|
||||
{% customfield_value field value %}
|
||||
</td>
|
||||
|
@ -29,17 +29,6 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
Account <i
|
||||
class="mdi mdi-alert-box text-warning"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="This field has been deprecated, and will be removed in NetBox v3.5."
|
||||
></i>
|
||||
</th>
|
||||
<td>{{ object.account|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
@ -54,7 +43,6 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -38,7 +38,6 @@
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
|
@ -298,8 +298,30 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Dimensions</h5>
|
||||
<div class="card-body table-responsive">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Height</th>
|
||||
<td>
|
||||
{{ object.device_type.u_height }}U
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Weight</th>
|
||||
<td>
|
||||
{% if object.total_weight %}
|
||||
{{ object.total_weight|floatformat }} Kilograms
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% if object.rack and object.position %}
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
|
@ -28,11 +28,25 @@
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Context Data</h5>
|
||||
<div class="accordion accordion-flush" id="renderConfig">
|
||||
<div class="card-body">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="renderConfigHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
|
||||
Context Data
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
|
||||
<div class="accordion-body">
|
||||
<pre class="card-body">{{ context_data|pprint }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
|
@ -123,11 +123,11 @@
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th scope="row">MAC Address</th>
|
||||
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td>
|
||||
<td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">WWN</th>
|
||||
<td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td>
|
||||
<td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">VRF</th>
|
||||
|
@ -65,7 +65,6 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
|
@ -51,7 +51,6 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -40,7 +40,6 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
|
@ -191,7 +191,6 @@
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -46,7 +46,6 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -87,11 +87,13 @@
|
||||
<th scope="row">Physical Address</th>
|
||||
<td class="position-relative">
|
||||
{% if object.physical_address %}
|
||||
{% if config.MAPS_URL %}
|
||||
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-map-marker"></i> Map
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
@ -106,11 +108,13 @@
|
||||
<th scope="row">GPS Coordinates</th>
|
||||
<td class="position-relative">
|
||||
{% if object.latitude and object.longitude %}
|
||||
{% if config.MAPS_URL %}
|
||||
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-map-marker"></i> Map It
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
@ -127,7 +131,6 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Locations</h5>
|
||||
<div class='card-body'>
|
||||
|
@ -42,7 +42,6 @@
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -1,10 +1,8 @@
|
||||
{% load helpers %}
|
||||
|
||||
{% if counts %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for model, count in counts %}
|
||||
{% for model, count, url in counts %}
|
||||
{% if count != None %}
|
||||
<a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action">
|
||||
<a href="{{ url }}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
{{ model|meta:"verbose_name_plural"|bettertitle }}
|
||||
<h6 class="mb-1">{{ count }}</h6>
|
||||
|
@ -37,6 +37,11 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' with object=module %}
|
||||
{% if not module.scripts %}
|
||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Script file at: {{module.full_path}} could not be loaded.
|
||||
</div>
|
||||
{% else %}
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -74,6 +79,7 @@
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
|
@ -1,63 +0,0 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Contacts</h5>
|
||||
<div class="card-body">
|
||||
{% with contacts=object.contacts.all %}
|
||||
{% if contacts.exists %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Priority</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td>{{ contact.contact|linkify }}</td>
|
||||
<td>{{ contact.role|placeholder }}</td>
|
||||
<td>{{ contact.get_priority_display|placeholder }}</td>
|
||||
<td>
|
||||
{% if contact.contact.phone %}
|
||||
<a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if contact.contact.email %}
|
||||
<a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end noprint">
|
||||
{% if perms.tenancy.change_contactassignment %}
|
||||
<a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.tenancy.delete_contactassignment %}
|
||||
<a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">None</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if perms.tenancy.add_contactassignment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
@ -12,8 +12,15 @@
|
||||
<table class="table table-hover attr-table">
|
||||
{% for field, value in fields.items %}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||
<th scope="row">{{ field }}
|
||||
{% if field.description %}
|
||||
<i
|
||||
class="mdi mdi-information text-primary"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="{{ field.description|escape }}"
|
||||
></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
<td>
|
||||
{% customfield_value field value %}
|
||||
|
@ -1,47 +1,8 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% with images=object.images.all %}
|
||||
{% if images.exists %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for attachment in images %}
|
||||
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
|
||||
<td>
|
||||
<i class="mdi mdi-file-image-outline"></i>
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created|annotated_date }}</td>
|
||||
<td class="text-end noprint">
|
||||
{% if perms.extras.change_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">None</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<h5 class="card-header">Images</h5>
|
||||
{% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %}
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
|
@ -3,13 +3,6 @@
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% if object.vrf %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-4">
|
||||
@ -116,7 +109,6 @@
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
|
||||
{% endif %}
|
||||
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Services</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
|
8
netbox/templates/ipam/ipaddress/base.html
Normal file
8
netbox/templates/ipam/ipaddress/base.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% if object.vrf %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
19
netbox/templates/ipam/ipaddress/ip_addresses.html
Normal file
19
netbox/templates/ipam/ipaddress/ip_addresses.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends 'ipam/ipaddress/base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="card-body" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user