diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 38a6fd550..54dc5ca8c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,24 +15,24 @@ about: Report a reproducible bug in the current release of NetBox Please describe the environment in which you are running NetBox. Be sure that you are running an unmodified instance of the latest stable release - before submitting a bug report. + before submitting a bug report, and that any plugins have been disabled. --> ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: ### Steps to Reproduce -1. -2. -3. +1. +2. +3. ### Expected Behavior diff --git a/.travis.yml b/.travis.yml index 8bd352dd5..0dcbd9ee1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,9 @@ services: - postgresql - redis-server addons: - postgresql: "9.4" + postgresql: "9.6" language: python python: - - "3.5" - "3.6" - "3.7" install: diff --git a/README.md b/README.md index be69a9e52..4203b7d87 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ ![NetBox](docs/netbox_logo.svg "NetBox logo") -**The [2020 NetBox user survey](https://docs.google.com/forms/d/1OVZuC4kQ-6kJbVf0bDB6vgkL9H96xF6phvYzby23elk/edit) is open!** Your feedback helps guide the project's long-term development. - NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically diff --git a/base_requirements.txt b/base_requirements.txt index e5838ef9b..caf7ba5f3 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -68,8 +68,7 @@ Jinja2 # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown -# py-gfm requires Markdown<3.0 -Markdown<3.0 +Markdown # Library for manipulating IP prefixes and addresses # https://github.com/drkjam/netaddr diff --git a/contrib/apache.conf b/contrib/apache.conf new file mode 100644 index 000000000..1804e380d --- /dev/null +++ b/contrib/apache.conf @@ -0,0 +1,26 @@ + + ProxyPreserveHost On + + # CHANGE THIS TO YOUR SERVER'S NAME + ServerName netbox.example.com + + SSLEngine on + SSLCertificateFile /etc/ssl/certs/netbox.crt + SSLCertificateKeyFile /etc/ssl/private/netbox.key + + Alias /static /opt/netbox/netbox/static + + + Options Indexes FollowSymLinks MultiViews + AllowOverride None + Require all granted + + + + ProxyPass ! + + + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} + ProxyPass / http://127.0.0.1:8001/ + ProxyPassReverse / http://127.0.0.1:8001/ + diff --git a/contrib/nginx.conf b/contrib/nginx.conf new file mode 100644 index 000000000..1230f3ce4 --- /dev/null +++ b/contrib/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 443 ssl; + + # CHANGE THIS TO YOUR SERVER'S NAME + server_name netbox.example.com; + + ssl_certificate /etc/ssl/certs/netbox.crt; + ssl_certificate_key /etc/ssl/private/netbox.key; + + client_max_body_size 25m; + + location /static/ { + alias /opt/netbox/netbox/static/; + } + + location / { + proxy_pass http://127.0.0.1:8001; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + # Redirect HTTP traffic to HTTPS + listen 80; + server_name _; + return 301 https://$host$request_uri; +} diff --git a/docker/configuration/configuration.py b/docker/configuration/configuration.py index af121d9ed..8860bac95 100644 --- a/docker/configuration/configuration.py +++ b/docker/configuration/configuration.py @@ -51,7 +51,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', read_secret('secret_key')) # Redis database settings. The Redis database is used for caching and background processing such as webhooks REDIS = { - 'webhooks': { + 'tasks': { 'HOST': os.environ.get('REDIS_HOST', 'localhost'), 'PORT': int(os.environ.get('REDIS_PORT', 6379)), 'PASSWORD': os.environ.get('REDIS_PASSWORD', read_secret('redis_password')), @@ -119,6 +119,10 @@ EMAIL = { 'PASSWORD': os.environ.get('EMAIL_PASSWORD', read_secret('email_password')), 'TIMEOUT': int(os.environ.get('EMAIL_TIMEOUT', 10)), # seconds 'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''), + 'USE_SSL': os.environ.get('EMAIL_USE_SSL', 'False').lower() == 'true', + 'USE_TLS': os.environ.get('EMAIL_USE_TLS', 'False').lower() == 'true', + 'SSL_CERTFILE': os.environ.get('EMAIL_SSL_CERTFILE', ''), + 'SSL_KEYFILE': os.environ.get('EMAIL_SSL_KEYFILE', ''), } # Enforcement of unique IP space can be toggled on a per-VRF basis. @@ -170,6 +174,15 @@ PAGINATE_COUNT = int(os.environ.get('PAGINATE_COUNT', 50)) # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to # prefer IPv4 instead. PREFER_IPV4 = os.environ.get('PREFER_IPV4', 'False').lower() == 'true' + +# This determines how often the GitHub API is called to check the latest release of NetBox in seconds. Must be at least 1 hour. +RELEASE_CHECK_TIMEOUT = os.environ.get('RELEASE_CHECK_TIMEOUT', 24 * 3600) + +# This repository is used to check whether there is a new release of NetBox available. Set to None to disable the +# version check or use the URL below to check for release in the official NetBox repository. +# https://api.github.com/repos/netbox-community/netbox/releases +RELEASE_CHECK_URL = os.environ.get('RELEASE_CHECK_URL', None) + # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. REPORTS_ROOT = os.environ.get('REPORTS_ROOT', '/etc/netbox/reports') diff --git a/docker/startup_scripts/070_rack_roles-required.py b/docker/startup_scripts/070_rack_roles-required.py index 0f43b7f74..94618b93a 100644 --- a/docker/startup_scripts/070_rack_roles-required.py +++ b/docker/startup_scripts/070_rack_roles-required.py @@ -1,6 +1,6 @@ from dcim.models import RackRole from ruamel.yaml import YAML -from utilities.forms import COLOR_CHOICES +from utilities.choices import ColorChoices from pathlib import Path import sys @@ -18,7 +18,7 @@ with file.open('r') as stream: if 'color' in params: color = params.pop('color') - for color_tpl in COLOR_CHOICES: + for color_tpl in ColorChoices: if color in color_tpl: params['color'] = color_tpl[0] diff --git a/docker/startup_scripts/090_device_roles-required.py b/docker/startup_scripts/090_device_roles-required.py index 45843369f..433f94a74 100644 --- a/docker/startup_scripts/090_device_roles-required.py +++ b/docker/startup_scripts/090_device_roles-required.py @@ -1,6 +1,6 @@ from dcim.models import DeviceRole from ruamel.yaml import YAML -from utilities.forms import COLOR_CHOICES +from utilities.choices import ColorChoices from pathlib import Path import sys @@ -19,7 +19,7 @@ with file.open('r') as stream: if 'color' in params: color = params.pop('color') - for color_tpl in COLOR_CHOICES: + for color_tpl in ColorChoices: if color in color_tpl: params['color'] = color_tpl[0] diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 0904f8c82..1d84fea24 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -63,7 +63,7 @@ A human-friendly description of what your script does. ### `field_order` -A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example: +A list of field names indicating the order in which the form fields should appear. This is optional, and should not be required on Python 3.6 and above. For example: ``` field_order = ['var1', 'var2', 'var3'] diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md index 0aa944b74..1429fb0a7 100644 --- a/docs/additional-features/prometheus-metrics.md +++ b/docs/additional-features/prometheus-metrics.md @@ -32,3 +32,7 @@ This can be setup by first creating a shared directory and then adding this line ``` environment=prometheus_multiproc_dir=/tmp/prometheus_metrics ``` + +#### Accuracy + +If having accurate long-term metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562). \ No newline at end of file diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 6deddc140..e845117c0 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -33,7 +33,6 @@ Within each report class, we'll create a number of test methods to execute our r ``` from dcim.choices import DeviceStatusChoices -from dcim.constants import CONNECTION_STATUS_PLANNED from dcim.models import ConsolePort, Device, PowerPort from extras.reports import Report @@ -51,7 +50,7 @@ class DeviceConnectionsReport(Report): console_port.device, "No console connection defined for {}".format(console_port.name) ) - elif console_port.connection_status == CONNECTION_STATUS_PLANNED: + elif not console_port.connection_status: self.log_warning( console_port.device, "Console connection for {} marked as planned".format(console_port.name) @@ -67,7 +66,7 @@ class DeviceConnectionsReport(Report): for power_port in PowerPort.objects.filter(device=device): if power_port.connected_endpoint is not None: connected_ports += 1 - if power_port.connection_status == CONNECTION_STATUS_PLANNED: + if not power_port.connection_status: self.log_warning( device, "Power connection for {} marked as planned".format(power_port.name) diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 310e67bf5..de06c50b7 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -71,3 +71,36 @@ If no body template is specified, the request body will be populated with a JSON When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under Django RQ > Queues. A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI. + +## Troubleshooting + +To assist with verifying that the content of outgoing webhooks is rendered correctly, NetBox provides a simple HTTP listener that can be run locally to receive and display webhook requests. First, modify the target URL of the desired webhook to `http://localhost:9000/`. This will instruct NetBox to send the request to the local server on TCP port 9000. Then, start the webhook receiver service from the NetBox root directory: + +```no-highlight +$ python netbox/manage.py webhook_receiver +Listening on port http://localhost:9000. Stop with CONTROL-C. +``` + +You can test the receiver itself by sending any HTTP request to it. For example: + +```no-highlight +$ curl -X POST http://localhost:9000 --data '{"foo": "bar"}' +``` + +The server will print output similar to the following: + +```no-highlight +[1] Tue, 07 Apr 2020 17:44:02 GMT 127.0.0.1 "POST / HTTP/1.1" 200 - +Host: localhost:9000 +User-Agent: curl/7.58.0 +Accept: */* +Content-Length: 14 +Content-Type: application/x-www-form-urlencoded + +{"foo": "bar"} +------------ +``` + +Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. + +Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI). diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index bae4471b8..34cd5a30f 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -10,8 +10,8 @@ This will launch a customized version of [the built-in Django shell](https://doc ``` $ ./manage.py nbshell -### NetBox interactive shell (jstretch-laptop) -### Python 3.5.2 | Django 2.0.8 | NetBox 2.4.3 +### NetBox interactive shell (localhost) +### Python 3.6.9 | Django 2.2.11 | NetBox 2.7.10 ### lsmodels() will show available models. Use help() for more info. ``` diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 8e38c4de9..e8e6ddc96 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -2,18 +2,7 @@ The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API. -## Tokens - -A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`. - -!!! note - The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. - -Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. - -By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. - -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +{!docs/models/users/token.md!} ## Authenticating to the API diff --git a/docs/api/filtering.md b/docs/api/filtering.md index e7b51d303..6bc47e75b 100644 --- a/docs/api/filtering.md +++ b/docs/api/filtering.md @@ -17,7 +17,7 @@ E.g. filtering based on a device's name: While you are able to filter based on an arbitrary number of fields, you are also able to pass multiple values for the same field. In most cases filtering on multiple values is -implemented as a logical OR operation. A notible exception is the `tag` filter which +implemented as a logical OR operation. A notable exception is the `tag` filter which is a logical AND. Passing multiple values for one field, can be combined with other fields. For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4: @@ -33,11 +33,11 @@ _both_ of those tags applied: ## Lookup Expressions -Certain model fields also support filtering using additonal lookup expressions. This allows +Certain model fields also support filtering using additional lookup expressions. This allows for negation and other context specific filtering. These lookup expressions can be applied by adding a suffix to the desired field's name. -E.g. `mac_address__n`. In this case, the filter expression is for negation and it is seperated +E.g. `mac_address__n`. In this case, the filter expression is for negation and it is separated by two underscores. Below are the lookup expressions that are supported across different field types. diff --git a/docs/api/overview.md b/docs/api/overview.md index 1d8a91084..8eefae027 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -187,37 +187,6 @@ GET /api/ipam/prefixes/13980/?brief=1 The brief format is supported for both lists and individual objects. -### Static Choice Fields - -Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL. - -Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return: - -``` -[ - { - "value": 0, - "label": "Container" - }, - { - "value": 1, - "label": "Active" - }, - { - "value": 2, - "label": "Reserved" - }, - { - "value": 3, - "label": "Deprecated" - } -] -``` - -Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`. - -A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app. - ## Pagination API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes: @@ -274,33 +243,38 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_ ## Filtering -A list of objects retrieved via the API can be filtered by passing one or more query parameters. The same parameters used by the web UI work for the API as well. For example, to return only prefixes with a status of "Active" (`1`): +A list of objects retrieved via the API can be filtered by passing one or more query parameters. The same parameters used by the web UI work for the API as well. For example, to return only prefixes with a status of "Active" (identified by the slug `active`): ``` -GET /api/ipam/prefixes/?status=1 +GET /api/ipam/prefixes/?status=active ``` -The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`: +The choices available for fixed choice fields such as `status` can be retrieved by sending an `OPTIONS` API request for the desired endpoint: -``` -"prefix:status": [ - { - "label": "Container", - "value": 0 - }, - { - "label": "Active", - "value": 1 - }, - { - "label": "Reserved", - "value": 2 - }, - { - "label": "Deprecated", - "value": 3 - } -], +```no-highlight +$ curl -s -X OPTIONS \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://localhost:8000/api/ipam/prefixes/ | jq ".actions.POST.status.choices" +[ + { + "value": "container", + "display_name": "Container" + }, + { + "value": "active", + "display_name": "Active" + }, + { + "value": "reserved", + "display_name": "Reserved" + }, + { + "value": "deprecated", + "display_name": "Deprecated" + } +] ``` For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar". diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 337b41b1b..3c4392915 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -86,7 +86,12 @@ CORS_ORIGIN_WHITELIST = [ Default: False -This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users. +This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients +which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user +interface. + +!!! warning + Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users. --- @@ -108,16 +113,20 @@ The file path to NetBox's documentation. This is used when presenting context-se ## EMAIL -In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting: +In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: -* SERVER - Host name or IP address of the email server (use `localhost` if running locally) -* PORT - TCP port to use for the connection (default: 25) -* USERNAME - Username with which to authenticate -* PASSSWORD - Password with which to authenticate -* TIMEOUT - Amount of time to wait for a connection (seconds) -* FROM_EMAIL - Sender address for emails sent by NetBox +* `SERVER` - Host name or IP address of the email server (use `localhost` if running locally) +* `PORT` - TCP port to use for the connection (default: `25`) +* `USERNAME` - Username with which to authenticate +* `PASSSWORD` - Password with which to authenticate +* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`. +* `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`. +* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) +* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional) +* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`) +* `FROM_EMAIL` - Sender address for emails sent by NetBox (default: `root@localhost`) -Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail): +Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail): ``` # python ./manage.py nbshell @@ -165,6 +174,31 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni --- +## HTTP_PROXIES + +Default: None + +A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhooks). Proxies should be specified by schema as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: + +```python +HTTP_PROXIES = { + 'http': 'http://10.10.1.10:3128', + 'https': 'http://10.10.1.10:1080', +} +``` + +--- + +## INTERNAL_IPS + +Default: `('127.0.0.1', '::1',)` + +A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For +example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP +addresses (and [`DEBUG`](#debug) is true). + +--- + ## LOGGING By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`. @@ -191,6 +225,14 @@ LOGGING = { } ``` +### Available Loggers + +* `netbox.auth.*` - Authentication events +* `netbox.api.views.*` - Views which handle business logic for the REST API +* `netbox.reports.*` - Report execution (`module.name`) +* `netbox.scripts.*` - Custom script execution (`module.name`) +* `netbox.views.*` - Views which handle business logic for the web UI + --- ## LOGIN_REQUIRED @@ -291,6 +333,39 @@ Determine how many objects to display per page within each list of objects. --- +## PLUGINS + +Default: Empty + +A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. + +!!! warning + Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. + +--- + +## PLUGINS_CONFIG + +Default: Empty + +This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: + +```python +PLUGINS_CONFIG = { + 'plugin1': { + 'foo': 123, + 'bar': True + }, + 'plugin2': { + 'foo': 456, + }, +} +``` + +Note that a plugin must be listed in `PLUGINS` for its configuration to take effect. + +--- + ## PREFER_IPV4 Default: False @@ -299,6 +374,72 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## REMOTE_AUTH_ENABLED + +Default: `False` + +NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) + +--- + +## REMOTE_AUTH_BACKEND + +Default: `'utilities.auth_backends.RemoteUserBackend'` + +Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_HEADER + +Default: `'HTTP_REMOTE_USER'` + +When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_AUTO_CREATE_USER + +Default: `False` + +If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_DEFAULT_GROUPS + +Default: `[]` (Empty list) + +The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_DEFAULT_PERMISSIONS + +Default: `[]` (Empty list) + +The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## RELEASE_CHECK_TIMEOUT + +Default: 86,400 (24 hours) + +The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). + +--- + +## RELEASE_CHECK_URL + +Default: None + +The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API. + +Use `'https://api.github.com/repos/netbox-community/netbox/releases'` to check for release in the official NetBox repository. + +--- + ## REPORTS_ROOT Default: $BASE_DIR/netbox/reports/ diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index e86b2810a..053e2d3d4 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -46,9 +46,9 @@ DATABASE = { [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching functionality (as well as other planned features). In 2.7, the connection settings were broken down into two sections for -webhooks and caching, allowing the user to connect to different Redis instances/databases per feature. +task queuing and caching, allowing the user to connect to different Redis instances/databases per feature. -Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `webhooks` and `caching` subsections: +Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `tasks` and `caching` subsections: * `HOST` - Name or IP address of the Redis server (use `localhost` if running locally) * `PORT` - TCP port of the Redis service; leave blank for default port (6379) @@ -61,7 +61,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', @@ -84,9 +84,9 @@ REDIS = { If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary -!!! note - It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the - same Redis instance for both may result in webhook processing data being lost during cache flushing events. +!!! warning + It is highly recommended to keep the task and cache databases separate. Using the same database number on the + same Redis instance for both may result in queued background tasks being lost during cache flushing events. ### Using Redis Sentinel @@ -102,7 +102,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', @@ -126,7 +126,7 @@ REDIS = { !!! note It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible - for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via + for example to have the tasks database use sentinel via `HOST`/`PORT` and for caching to use Sentinel via `SENTINELS`/`SENTINEL_SERVICE`. diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md new file mode 100644 index 000000000..67479b6fb --- /dev/null +++ b/docs/development/application-registry.md @@ -0,0 +1,55 @@ +# Application Registry + +The registry is an in-memory data structure which houses various miscellaneous application-wide parameters, such as installed plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core. + +The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, me modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized. + +## Stores + +### `model_features` + +A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example: + +```python +{ + 'custom_fields': { + 'circuits': ['provider', 'circuit'], + 'dcim': ['site', 'rack', 'devicetype', ...], + ... + }, + 'webhooks': { + ... + }, + ... +} +``` + +### `plugin_menu_items` + +Navigation menu items provided by NetBox plugins. Each plugin is registered as a key with the list of menu items it provides. An example: + +```python +{ + 'Plugin A': ( + , , , + ), + 'Plugin B': ( + , , , + ), +} +``` + +### `plugin_template_extensions` + +Plugin content that gets embedded into core NetBox templates. The store comprises NetBox models registered as dictionary keys, each pointing to a list of applicable template extension classes that exist. An example: + +```python +{ + 'dcim.site': [ + , , , + ], + 'dcim.rack': [ + , , + ], +} +``` diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index f5244bff5..d924d2c0b 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -35,13 +35,9 @@ Update the following static libraries to their most recent stable release: * jQuery * jQuery UI -### Squash Schema Migrations - -Database schema migrations should be squashed for each new minor release. See the [squashing guide](squashing-migrations.md) for the detailed process. - ### Create a new Release Notes Page -Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for the new release. Add the file to the table of contents within `mkdocs.yml`. +Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for the new release. Add the file to the table of contents within `mkdocs.yml`, and point `index.md` to the new file. ### Manually Perform a New Install diff --git a/docs/development/squashing-migrations.md b/docs/development/squashing-migrations.md deleted file mode 100644 index bc0c0548f..000000000 --- a/docs/development/squashing-migrations.md +++ /dev/null @@ -1,168 +0,0 @@ -# Squashing Database Schema Migrations - -## What are Squashed Migrations? - -The Django framework on which NetBox is built utilizes [migration files](https://docs.djangoproject.com/en/stable/topics/migrations/) to keep track of changes to the PostgreSQL database schema. Each time a model is altered, the resulting schema change is captured in a migration file, which can then be applied to effect the new schema. - -As changes are made over time, more and more migration files are created. Although not necessarily problematic, it can be beneficial to merge and compress these files occasionally to reduce the total number of migrations that need to be applied upon installation of NetBox. This merging process is called _squashing_ in Django vernacular, and results in two parallel migration paths: individual and squashed. - -Below is an example showing both individual and squashed migration files within an app: - -| Individual | Squashed | -|------------|----------| -| 0001_initial | 0001_initial_squashed_0004_add_field | -| 0002_alter_field | . | -| 0003_remove_field | . | -| 0004_add_field | . | -| 0005_another_field | 0005_another_field | - -In the example above, a new installation can leverage the squashed migrations to apply only two migrations: - -* `0001_initial_squashed_0004_add_field` -* `0005_another_field` - -This is because the squash file contains all of the operations performed by files `0001` through `0004`. - -However, an existing installation that has already applied some of the individual migrations contained within the squash file must continue applying individual migrations. For instance, an installation which currently has up to `0002_alter_field` applied must apply the following migrations to become current: - -* `0003_remove_field` -* `0004_add_field` -* `0005_another_field` - -Squashed migrations are opportunistic: They are used only if applicable to the current environment. Django will fall back to using individual migrations if the squashed migrations do not agree with the current database schema at any point. - -## Squashing Migrations - -During every minor (i.e. 2.x) release, migrations should be squashed to help simplify the migration process for new installations. The process below describes how to squash migrations efficiently and with minimal room for error. - -### 1. Create a New Branch - -Create a new branch off of the `develop-2.x` branch. (Migrations should be squashed _only_ in preparation for a new minor release.) - -``` -git checkout -B squash-migrations -``` - -### 2. Delete Existing Squash Files - -Delete the most recent squash file within each NetBox app. This allows us to extend squash files where the opportunity exists. For example, we might be able to replace `0005_to_0008` with `0005_to_0011`. - -### 3. Generate the Current Migration Plan - -Use Django's `showmigrations` utility to display the order in which all migrations would be applied for a new installation. - -``` -manage.py showmigrations --plan -``` - -From the resulting output, delete all lines which reference an external migration. Any migrations imposed by Django itself on an external package are not relevant. - -### 4. Create Squash Files - -Begin iterating through the migration plan, looking for successive sets of migrations within an app. These are candidates for squashing. For example: - -``` -[X] extras.0014_configcontexts -[X] extras.0015_remove_useraction -[X] extras.0016_exporttemplate_add_cable -[X] extras.0017_exporttemplate_mime_type_length -[ ] extras.0018_exporttemplate_add_jinja2 -[ ] extras.0019_tag_taggeditem -[X] dcim.0062_interface_mtu -[X] dcim.0063_device_local_context_data -[X] dcim.0064_remove_platform_rpc_client -[ ] dcim.0065_front_rear_ports -[X] circuits.0001_initial_squashed_0010_circuit_status -[ ] dcim.0066_cables -... -``` - -Migrations `0014` through `0019` in `extras` can be squashed, as can migrations `0062` through `0065` in `dcim`. Migration `0066` cannot be included in the same squash file, because the `circuits` migration must be applied before it. (Note that whether or not each migration is currently applied to the database does not matter.) - -Squash files are created using Django's `squashmigrations` utility: - -``` -manage.py squashmigrations -``` - -For example, our first step in the example would be to run `manage.py squashmigrations extras 0014 0019`. - -!!! note - Specifying a migration file's numeric index is enough to uniquely identify it within an app. There is no need to specify the full filename. - -This will create a new squash file within the app's `migrations` directory, named as a concatenation of its beginning and ending migration. Some manual editing is necessary for each new squash file for housekeeping purposes: - -* Remove the "automatically generated" comment at top (to indicate that a human has reviewed the file). -* Reorder `import` statements as necessary per PEP8. -* It may be necessary to copy over custom functions from the original migration files (this will be indicated by a comment near the top of the squash file). It is safe to remove any functions that exist solely to accomodate reverse migrations (which we no longer support). - -Repeat this process for each candidate set of migrations until you reach the end of the migration plan. - -### 5. Check for Missing Migrations - -If everything went well, at this point we should have a completed squashed path. Perform a dry run to check for any missing migrations: - -``` -manage.py migrate --dry-run -``` - -### 5. Run Migrations - -Next, we'll apply the entire migration path to an empty database. Begin by dropping and creating your development database. - -!!! warning - Obviously, first back up any data you don't want to lose. - -``` -sudo -u postgres psql -c 'drop database netbox' -sudo -u postgres psql -c 'create database netbox' -``` - -Apply the migrations with the `migrate` management command. It is not necessary to specify a particular migration path; Django will detect and use the squashed migrations automatically. You can verify the exact migrations being applied by enabling verboes output with `-v 2`. - -``` -manage.py migrate -v 2 -``` - -### 6. Commit the New Migrations - -If everything is successful to this point, commit your changes to the `squash-migrations` branch. - -### 7. Validate Resulting Schema - -To ensure our new squashed migrations do not result in a deviation from the original schema, we'll compare the two. With the new migration file safely commit, check out the `develop-2.x` branch, which still contains only the individual migrations. - -``` -git checkout develop-2.x -``` - -Temporarily install the [django-extensions](https://django-extensions.readthedocs.io/) package, which provides the `sqldiff utility`: - -``` -pip install django-extensions -``` - -Also add `django_extensions` to `INSTALLED_APPS` in `netbox/netbox/settings.py`. - -At this point, our database schema has been defined by using the squashed migrations. We can run `sqldiff` to see if it differs any from what the current (non-squashed) migrations would generate. `sqldiff` accepts a list of apps against which to run: - -``` -manage.py sqldiff circuits dcim extras ipam secrets tenancy users virtualization -``` - -It is safe to ignore errors indicating an "unknown database type" for the following fields: - -* `dcim_interface.mac_address` -* `ipam_aggregate.prefix` -* `ipam_prefix.prefix` - -It is also safe to ignore the message "Table missing: extras_script". - -Resolve any differences by correcting migration files in the `squash-migrations` branch. - -!!! warning - Don't forget to remove `django_extension` from `INSTALLED_APPS` before committing your changes. - -### 8. Merge the Squashed Migrations - -Once all squashed migrations have been validated and all tests run successfully, merge the `squash-migrations` branch into `develop-2.x`. This completes the squashing process. diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md new file mode 100644 index 000000000..80088186c --- /dev/null +++ b/docs/development/user-preferences.md @@ -0,0 +1,11 @@ +# User Preferences + +The `users.UserConfig` model holds individual preferences for each user in the form of JSON data. This page serves as a manifest of all recognized user preferences in NetBox. + +## Available Preferences + +| Name | Description | +| ---- | ----------- | +| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table_name}.columns | The ordered list of columns to display when viewing the table | diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 000000000..6a95f356a --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,19 @@ +/* Images */ +img { + display: block; + margin-left: auto; + margin-right: auto; +} + +/* Tables */ +table { + margin-bottom: 24px; + width: 100%; +} +th { + background-color: #f0f0f0; + padding: 6px; +} +td { + padding: 6px; +} diff --git a/docs/index.md b/docs/index.md index 4db2c55f5..ee7f77f69 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,13 +49,13 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 9.4+ | +| Database | PostgreSQL 9.6+ | | Task queuing | Redis/django-rq | | Live device access | NAPALM | ## Supported Python Versions -NetBox supports Python 3.5, 3.6, and 3.7 environments currently. Python 3.5 is scheduled to be unsupported in NetBox v2.8. +NetBox supports Python 3.6 and 3.7 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.) ## Getting Started diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 414c3c907..933e32edc 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -3,7 +3,7 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). !!! warning - NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported. + NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported. The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. @@ -20,10 +20,10 @@ If a recent enough version of PostgreSQL is not available through your distribut #### CentOS -CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6. +CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version. ```no-highlight -# yum install -y https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm +# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm # yum install -y postgresql96 postgresql96-server postgresql96-devel # /usr/pgsql-9.6/bin/postgresql96-setup initdb ``` @@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a ```no-highlight # sudo -u postgres psql -psql (9.4.5) +psql (10.10) Type "help" for help. postgres=# CREATE DATABASE netbox; diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index fabad20eb..c583d08fe 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -1,13 +1,15 @@ # NetBox Installation -This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies: +This section of the documentation discusses installing and configuring the NetBox application itself. ## Install System Packages +Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required. + ### Ubuntu ```no-highlight -# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` ### CentOS @@ -76,7 +78,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s CentOS users may need to create the `netbox` group first. ``` -# adduser --system --group netbox +# groupadd --system netbox +# adduser --system --gid netbox netbox # chown --recursive netbox /opt/netbox/netbox/media/ ``` @@ -172,7 +175,7 @@ Redis is a in-memory key-value store required as part of the NetBox installation ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', diff --git a/docs/installation/4-http-daemon.md b/docs/installation/4-http-daemon.md index 4ab28dca7..b93bdc3ef 100644 --- a/docs/installation/4-http-daemon.md +++ b/docs/installation/4-http-daemon.md @@ -5,6 +5,18 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for !!! info For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed. +## Obtain an SSL Certificate + +To enable HTTPS access to NetBox, you'll need a valid SSL certificate. You can purchase one from a trusted commercial provider, obtain one for free from [Let's Encrypt](https://letsencrypt.org/getting-started/), or generate your own (although self-signed certificates are generally untrusted). Both the public certificate and private key files need to be installed on your NetBox server in a location that is readable by the `netbox` user. + +The command below can be used to generate a self-signed certificate for testing purposes, however it is strongly recommended to use a certificate from a trusted authority in production. Two files will be created: the public certificate (`netbox.crt`) and the private key (`netbox.key`). The certificate is published to the world, whereas the private key must be kept secret at all times. + +```no-highlight +# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ +-keyout /etc/ssl/private/netbox.key \ +-out /etc/ssl/certs/netbox.crt +``` + ## HTTP Daemon Installation ### Option A: nginx @@ -15,27 +27,10 @@ The following will serve as a minimal nginx configuration. Be sure to modify you # apt-get install -y nginx ``` -Once nginx is installed, save the following configuration to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.) +Once nginx is installed, copy the default nginx configuration file to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.) -```nginx -server { - listen 80; - - server_name netbox.example.com; - - client_max_body_size 25m; - - location /static/ { - alias /opt/netbox/netbox/static/; - } - - location / { - proxy_pass http://127.0.0.1:8001; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - } -} +```no-highlight +# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox ``` Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created. @@ -46,65 +41,38 @@ Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sit # ln -s /etc/nginx/sites-available/netbox ``` -Restart the nginx service to use the new configuration. +Finally, restart the `nginx` service to use the new configuration. ```no-highlight # service nginx restart ``` -To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04). - ### Option B: Apache -```no-highlight -# apt-get install -y apache2 libapache2-mod-wsgi-py3 -``` - -Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately): - -```apache - - ProxyPreserveHost On - - ServerName netbox.example.com - - Alias /static /opt/netbox/netbox/static - - # Needed to allow token-based API authentication - WSGIPassAuthorization on - - - Options Indexes FollowSymLinks MultiViews - AllowOverride None - Require all granted - - - - ProxyPass ! - - - RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} - ProxyPass / http://127.0.0.1:8001/ - ProxyPassReverse / http://127.0.0.1:8001/ - -``` - -Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache: +Begin by installing Apache: ```no-highlight -# a2enmod proxy -# a2enmod proxy_http -# a2enmod headers +# apt-get install -y apache2 +``` + +Next, copy the default configuration file to `/etc/apache2/sites-available/`. Be sure to modify the `ServerName` parameter appropriately. + +```no-highlight +# cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf +``` + +Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache: + +```no-highlight +# a2enmod ssl proxy proxy_http headers # a2ensite netbox # service apache2 restart ``` -To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-16-04). - !!! note Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox. -## gunicorn Configuration +## Gunicorn Configuration Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.) @@ -113,7 +81,7 @@ Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a # cp contrib/gunicorn.py /opt/netbox/gunicorn.py ``` -You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. +You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters. ## systemd Configuration @@ -133,7 +101,7 @@ Then, start the `netbox` and `netbox-rq` services and enable them to initiate at You can use the command `systemctl status netbox` to verify that the WSGI service is running: -``` +```no-highlight # systemctl status netbox.service ● netbox.service - NetBox WSGI Service Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) diff --git a/docs/installation/5-ldap.md b/docs/installation/5-ldap.md index b263ae040..2fd88b841 100644 --- a/docs/installation/5-ldap.md +++ b/docs/installation/5-ldap.md @@ -135,7 +135,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 ## Troubleshooting LDAP -`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`. +`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`. For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`. diff --git a/docs/installation/index.md b/docs/installation/index.md index b3f63bcb6..4c904e953 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -8,6 +8,10 @@ The following sections detail how to set up a new instance of NetBox: 4. [HTTP daemon](4-http-daemon.md) 5. [LDAP authentication](5-ldap.md) (optional) +Below is a simplified overview of the NetBox application stack for reference: + +![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png) + ## Upgrading If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index a888f18aa..65f42a6bc 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -7,7 +7,7 @@ This document contains instructions for migrating from a legacy NetBox deploymen ### Uninstall supervisord ```no-highlight -# apt-get remove -y supervisord +# apt-get remove -y supervisor ``` ### Configure systemd diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 83cd59d1d..c34fef954 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -4,6 +4,9 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. +!!! note + Beginning with version 2.8, NetBox requires Python 3.6 or later. + ## Install the Latest Code As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. diff --git a/docs/media/installation/netbox_application_stack.png b/docs/media/installation/netbox_application_stack.png new file mode 100644 index 000000000..e86344900 Binary files /dev/null and b/docs/media/installation/netbox_application_stack.png differ diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png new file mode 100644 index 000000000..44802c5fc Binary files /dev/null and b/docs/media/plugins/plugin_admin_ui.png differ diff --git a/docs/media/plugins/plugin_rest_api_endpoint.png b/docs/media/plugins/plugin_rest_api_endpoint.png new file mode 100644 index 000000000..7cdf34cc8 Binary files /dev/null and b/docs/media/plugins/plugin_rest_api_endpoint.png differ diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md index ad9df4eef..f5b2428e6 100644 --- a/docs/models/dcim/rackgroup.md +++ b/docs/models/dcim/rackgroup.md @@ -2,6 +2,6 @@ Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. -Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported. +Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy. The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.) diff --git a/docs/models/tenancy/tenantgroup.md b/docs/models/tenancy/tenantgroup.md index 48d9f4b6e..a2ed7e324 100644 --- a/docs/models/tenancy/tenantgroup.md +++ b/docs/models/tenancy/tenantgroup.md @@ -1,3 +1,5 @@ # Tenant Groups Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. + +Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team. diff --git a/docs/models/users/token.md b/docs/models/users/token.md new file mode 100644 index 000000000..bbeb2284b --- /dev/null +++ b/docs/models/users/token.md @@ -0,0 +1,12 @@ +## Tokens + +A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`. + +!!! note + The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. + +Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. + +By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. + +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. diff --git a/docs/plugins/development.md b/docs/plugins/development.md new file mode 100644 index 000000000..ad7eef310 --- /dev/null +++ b/docs/plugins/development.md @@ -0,0 +1,392 @@ +# Plugin Development + +This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. + +Plugins can do a lot, including: + +* Create Django models to store data in the database +* Provide their own "pages" (views) in the web user interface +* Inject template content and navigation links +* Establish their own REST API endpoints +* Add custom request/response middleware + +However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. + +## Initial Setup + +## Plugin Structure + +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: + +```no-highlight +plugin_name/ + - plugin_name/ + - templates/ + - plugin_name/ + - *.html + - __init__.py + - middleware.py + - navigation.py + - signals.py + - template_content.py + - urls.py + - views.py + - README + - setup.py +``` + +The top level is the project root. Immediately within the root should exist several items: + +* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. +* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. +* The plugin source directory, with the same name as your plugin. + +The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. + +### Create setup.py + +`setup.py` is the [setup script](https://docs.python.org/3.6/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: + +```python +from setuptools import find_packages, setup + +setup( + name='netbox-animal-sounds', + version='0.1', + description='An example NetBox plugin', + url='https://github.com/netbox-community/netbox-animal-sounds', + author='Jeremy Stretch', + license='Apache 2.0', + install_requires=[], + packages=find_packages(), + include_package_data=True, +) +``` + +Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). + +### Define a PluginConfig + +The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: + +```python +from extras.plugins import PluginConfig + +class AnimalSoundsConfig(PluginConfig): + name = 'netbox_animal_sounds' + verbose_name = 'Animal Sounds' + description = 'An example plugin for development purposes' + version = '0.1' + author = 'Jeremy Stretch' + author_email = 'author@example.com' + base_url = 'animal-sounds' + required_settings = [] + default_settings = { + 'loud': False + } + +config = AnimalSoundsConfig +``` + +NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. + +#### PluginConfig Attributes + +| Name | Description | +| ---- | ----------- | +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `caching_config` | Plugin-specific cache configuration +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | + +### Install the Plugin for Development + +To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): + +```no-highlight +$ python setup.py develop +``` + +## Database Models + +If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. + +Below is an example `models.py` file containing a model with two character fields: + +```python +from django.db import models + +class Animal(models.Model): + name = models.CharField(max_length=50) + sound = models.CharField(max_length=50) + + def __str__(self): + return self.name +``` + +Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. + +!!! note + A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. + +```no-highlight +$ ./manage.py makemigrations netbox_animal_sounds +Migrations for 'netbox_animal_sounds': + /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py + - Create model Animal +``` + +Next, we can apply the migration to the database with the `migrate` command: + +```no-highlight +$ ./manage.py migrate netbox_animal_sounds +Operations to perform: + Apply all migrations: netbox_animal_sounds +Running migrations: + Applying netbox_animal_sounds.0001_initial... OK +``` + +For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). + +### Using the Django Admin Interface + +Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below: + +```python +from django.contrib import admin +from .models import Animal + +@admin.register(Animal) +class AnimalAdmin(admin.ModelAdmin): + list_display = ('name', 'sound') +``` + +This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. + +![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) + +## Views + +If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: + +```python +from django.shortcuts import render +from django.views.generic import View +from .models import Animal + +class RandomAnimalView(View): + """ + Display a randomly-selected animal. + """ + def get(self, request): + animal = Animal.objects.order_by('?').first() + return render(request, 'netbox_animal_sounds/animal.html', { + 'animal': animal, + }) +``` + +This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create `animal.html`: + +```jinja2 +{% extends 'base.html' %} + +{% block content %} +{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} +

+ {% if animal %} + The {{ animal.name|lower }} says + {% if config.loud %} + {{ animal.sound|upper }}! + {% else %} + {{ animal.sound }} + {% endif %} + {% else %} + No animals have been created yet! + {% endif %} +

+{% endwith %} +{% endblock %} + +``` + +The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. + +Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. + +```python +from django.urls import path +from . import views + +urlpatterns = [ + path('random/', views.RandomAnimalView.as_view(), name='random_animal'), +] +``` + +A URL pattern has three components: + +* `route` - The unique portion of the URL dedicated to this view +* `view` - The view itself +* `name` - A short name used to identify the URL path internally + +This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. + +## REST API Endpoints + +Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. + +First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: + +```python +from rest_framework.serializers import ModelSerializer +from netbox_animal_sounds.models import Animal + +class AnimalSerializer(ModelSerializer): + + class Meta: + model = Animal + fields = ('id', 'name', 'sound') +``` + +Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: + +```python +from rest_framework.viewsets import ModelViewSet +from netbox_animal_sounds.models import Animal +from .serializers import AnimalSerializer + +class AnimalViewSet(ModelViewSet): + queryset = Animal.objects.all() + serializer_class = AnimalSerializer +``` + +Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. + +```python +from rest_framework import routers +from .views import AnimalViewSet + +router = routers.DefaultRouter() +router.register('animals', AnimalViewSet) +urlpatterns = router.urls +``` + +With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. + +![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png) + +!!! warning + This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. + +## Navigation Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +A `PluginMenuItem` has the following attributes: + +* `link` - The name of the URL path to which this menu item links +* `link_text` - The text presented to the user +* `permissions` - A list of permissions required to display this link (optional) +* `buttons` - An iterable of PluginMenuButton instances to display (optional) + +A `PluginMenuButton` has the following attributes: + +* `link` - The name of the URL path to which this button links +* `title` - The tooltip text (displayed when the mouse hovers over the button) +* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) +* `color` - One of the choices provided by `ButtonColorChoices` (optional) +* `permissions` - A list of permissions required to display this button (optional) + +## Extending Core Templates + +Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: + +* `left_page()` - Inject content on the left side of the page +* `right_page()` - Inject content on the right side of the page +* `full_width_page()` - Inject content across the entire bottom of the page +* `buttons()` - Add buttons to the top of the page + +Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. + +When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: + +* `object` - The object being viewed +* `request` - The current request +* `settings` - Global NetBox settings +* `config` - Plugin-specific configuration parameters + +For example, accessing `{{ request.user }}` within a template will return the current user. + +Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. + +```python +from extras.plugins import PluginTemplateExtension +from .models import Animal + +class SiteAnimalCount(PluginTemplateExtension): + model = 'dcim.site' + + def right_page(self): + return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ + 'animal_count': Animal.objects.count(), + }) + +template_extensions = [SiteAnimalCount] +``` + +## Caching Configuration + +By default, all query operations within a plugin are cached. To change this, define a caching configuration under the PluginConfig class' `caching_config` attribute. All configuration keys will be applied within the context of the plugin; there is no need to include the plugin name. An example configuration is below: + +```python +class MyPluginConfig(PluginConfig): + ... + caching_config = { + 'foo': { + 'ops': 'get', + 'timeout': 60 * 15, + }, + '*': { + 'ops': 'all', + } + } +``` + +To disable caching for your plugin entirely, set: + +```python +caching_config = { + '*': None +} +``` + +See the [django-cacheops](https://github.com/Suor/django-cacheops) documentation for more detail on configuring caching. diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 000000000..1f5587539 --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,82 @@ +# Plugins + +Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own. + +Plugins are supported on NetBox v2.8 and later. + +## Capabilities + +The NetBox plugin architecture allows for the following: + +* **Add new data models.** A plugin can introduce one or more models to hold data. (A model is essentially a table in the SQL database.) +* **Add new URLs and views.** Plugins can register URLs under the `/plugins` root path to provide browsable views for users. +* **Add content to existing model templates.** A template content class can be used to inject custom HTML content within the view of a core NetBox model. This content can appear in the left side, right side, or bottom of the page. +* **Add navigation menu items.** Each plugin can register new links in the navigation menu. Each link may have a set of buttons for specific actions, similar to the built-in navigation items. +* **Add custom middleware.** Custom Django middleware can be registered by each plugin. +* **Declare configuration parameters.** Each plugin can define required, optional, and default configuration parameters within its unique namespace. Plug configuration parameter are defined by the user under `PLUGINS_CONFIG` in `configuration.py`. +* **Limit installation by NetBox version.** A plugin can specify a minimum and/or maximum NetBox version with which it is compatible. + +## Limitations + +Either by policy or by technical limitation, the interaction of plugins with NetBox core is restricted in certain ways. A plugin may not: + +* **Modify core models.** Plugins may not alter, remove, or override core NetBox models in any way. This rule is in place to ensure the integrity of the core data model. +* **Register URLs outside the `/plugins` root.** All plugin URLs are restricted to this path to prevent path collisions with core or other plugins. +* **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content. +* **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration. +* **Disable core components.** Plugins are not permitted to disable or hide core NetBox components. + +## Installing Plugins + +The instructions below detail the process for installing and enabling a NetBox plugin. + +### Install Package + +Download and install the plugin package per its installation instructions. Plugins published via PyPI are typically installed using pip. Be sure to install the plugin within NetBox's virtual environment. + +```no-highlight +$ source /opt/netbox/venv/bin/activate +(venv) $ pip install +``` + +Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead. + +### Enable the Plugin + +In `configuration.py`, add the plugin's name to the `PLUGINS` list: + +```python +PLUGINS = [ + 'plugin_name', +] +``` + +### Configure Plugin + +If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file. + +```no-highlight +PLUGINS_CONFIG = { + 'plugin_name': { + 'foo': 'bar', + 'buzz': 'bazz' + } +} +``` + +### Collect Static Files + +Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command: + +```no-highlight +(venv) $ cd /opt/netbox/netbox/ +(venv) $ python3 manage.py collectstatic +``` + +### Restart WSGI Service + +Restart the WSGI service to load the new plugin: + +```no-highlight +# sudo systemctl restart netbox +``` diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index e44a306fe..364b2cd9d 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.7.md \ No newline at end of file +version-2.8.md \ No newline at end of file diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 69be137d7..e0297a692 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,50 @@ # NetBox v2.7 Release Notes +## v2.7.12 (2020-04-08) + +### Enhancements + +* [#3676](https://github.com/netbox-community/netbox/issues/3676) - Reference VRF by name rather than RD during IP/prefix import +* [#4147](https://github.com/netbox-community/netbox/issues/4147) - Use absolute URLs in rack elevation SVG renderings +* [#4448](https://github.com/netbox-community/netbox/issues/4448) - Allow connecting cables between two circuit terminations +* [#4460](https://github.com/netbox-community/netbox/issues/4460) - Add the `webhook_receiver` management command to assist in troubleshooting outgoing webhooks + +### Bug Fixes + +* [#4395](https://github.com/netbox-community/netbox/issues/4395) - Fix typing of count_ipaddresses on interface serializer +* [#4418](https://github.com/netbox-community/netbox/issues/4418) - Fail cleanly when trying to import multiple device types simultaneously +* [#4438](https://github.com/netbox-community/netbox/issues/4438) - Fix exception when disconnecting a cable from a power feed +* [#4439](https://github.com/netbox-community/netbox/issues/4439) - Tweak display of unset custom integer fields +* [#4449](https://github.com/netbox-community/netbox/issues/4449) - Fix reservation edit/delete button URLs on rack view + +--- + +## v2.7.11 (2020-03-27) + +### Enhancements + +* [#738](https://github.com/netbox-community/netbox/issues/738) - Add ability to automatically check for new releases (must be enabled by setting `RELEASE_CHECK_URL`) +* [#4255](https://github.com/netbox-community/netbox/issues/4255) - Custom script object variables now utilize dynamic form widgets +* [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views +* [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations +* [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations +* [#4381](https://github.com/netbox-community/netbox/issues/4381) - Enable export templates for rack reservations +* [#4382](https://github.com/netbox-community/netbox/issues/4382) - Enable custom links for rack reservations +* [#4386](https://github.com/netbox-community/netbox/issues/4386) - Update admin links for Django RQ to reflect multiple queues +* [#4389](https://github.com/netbox-community/netbox/issues/4389) - Add a bulk edit view for device bays +* [#4404](https://github.com/netbox-community/netbox/issues/4404) - Add cable trace button for circuit terminations + +### Bug Fixes + +* [#2769](https://github.com/netbox-community/netbox/issues/2769) - Improve `prefix_length` validation on available-prefixes API +* [#3193](https://github.com/netbox-community/netbox/issues/3193) - Fix cable tracing across multiple rear ports +* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API +* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables +* [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view +* [#4415](https://github.com/netbox-community/netbox/issues/4415) - Fix duplicate name validation on device model + +--- + ## v2.7.10 (2020-03-10) **Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt. diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md new file mode 100644 index 000000000..5ca86217a --- /dev/null +++ b/docs/release-notes/version-2.8.md @@ -0,0 +1,180 @@ +# NetBox v2.8 + +## v2.8.5 (2020-05-26) + +**Note:** The minimum required version of PostgreSQL is now 9.6. + +### Enhancements + +* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter +* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates +* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates +* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types +* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles + +### Bug Fixes + +* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses +* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar +* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent +* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name +* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces +* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices +* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses +* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs +* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Respect `comments` field when importing device type in YAML/JSON format + +--- + +## v2.8.4 (2020-05-13) + +### Enhancements + +* [#4632](https://github.com/netbox-community/netbox/issues/4632) - Extend email configuration parameters to support SSL/TLS + +### Bug Fixes + +* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified +* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports +* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens +* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527) +* [#4617](https://github.com/netbox-community/netbox/issues/4617) - Restore IP prefix depth notation in list view +* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses +* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0 +* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition + +--- + +## v2.8.3 (2020-05-06) + +### Bug Fixes + +* [#4593](https://github.com/netbox-community/netbox/issues/4593) - Fix AttributeError exception when viewing object lists as a non-authenticated user + +--- + +## v2.8.2 (2020-05-06) + +### Enhancements + +* [#492](https://github.com/netbox-community/netbox/issues/492) - Enable toggling and rearranging table columns +* [#3147](https://github.com/netbox-community/netbox/issues/3147) - Allow specifying related objects by arbitrary attribute during CSV import +* [#3064](https://github.com/netbox-community/netbox/issues/3064) - Include tags in object lists as a toggleable table column +* [#3294](https://github.com/netbox-community/netbox/issues/3294) - Implement mechanism for storing user preferences +* [#4421](https://github.com/netbox-community/netbox/issues/4421) - Retain user's preference for config context format +* [#4502](https://github.com/netbox-community/netbox/issues/4502) - Enable configuration of proxies for outbound HTTP requests +* [#4531](https://github.com/netbox-community/netbox/issues/4531) - Retain user's preference for page length +* [#4554](https://github.com/netbox-community/netbox/issues/4554) - Add ServerTech's HDOT Cx power outlet type + +### Bug Fixes + +* [#4527](https://github.com/netbox-community/netbox/issues/4527) - Fix assignment of certain tags to config contexts +* [#4545](https://github.com/netbox-community/netbox/issues/4545) - Removed all squashed schema migrations to allow direct upgrades from very old releases +* [#4548](https://github.com/netbox-community/netbox/issues/4548) - Fix tracing cables through a single RearPort +* [#4549](https://github.com/netbox-community/netbox/issues/4549) - Fix encoding unicode webhook body data +* [#4556](https://github.com/netbox-community/netbox/issues/4556) - Update form for adding devices to clusters +* [#4578](https://github.com/netbox-community/netbox/issues/4578) - Prevent setting 0U height on device type with racked instances +* [#4584](https://github.com/netbox-community/netbox/issues/4584) - Ensure consistent support for filtering objects by `id` across all REST API endpoints +* [#4588](https://github.com/netbox-community/netbox/issues/4588) - Restore ability to add/remove tags on services, virtual chassis in bulk + +--- + +## v2.8.1 (2020-04-23) + +### Notes + +In accordance with the fix in [#4459](https://github.com/netbox-community/netbox/issues/4459), users that are experiencing invalid nested data with +regions, rack groups, or tenant groups can perform a one-time operation using the NetBox shell to rebuild the correct nested relationships after upgrading: + +```text +$ python netbox/manage.py nbshell +### NetBox interactive shell (localhost) +### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1 +### lsmodels() will show available models. Use help() for more info. +>>> Region.objects.rebuild() +>>> RackGroup.objects.rebuild() +>>> TenantGroup.objects.rebuild() +``` + +### Enhancements + +* [#4464](https://github.com/netbox-community/netbox/issues/4464) - Add 21-inch rack width (ETSI) + +### Bug Fixes + +* [#2994](https://github.com/netbox-community/netbox/issues/2994) - Prevent modifying termination points of existing cable to ensure end-to-end path integrity +* [#3356](https://github.com/netbox-community/netbox/issues/3356) - Correct Swagger schema specification for the available prefixes/IPs API endpoints +* [#4139](https://github.com/netbox-community/netbox/issues/4139) - Enable assigning all relevant attributes during bulk device/VM component creation +* [#4336](https://github.com/netbox-community/netbox/issues/4336) - Ensure interfaces without a subinterface ID are ordered before subinterface zero +* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in Swagger schema +* [#4388](https://github.com/netbox-community/netbox/issues/4388) - Fix detection of connected endpoints when connecting rear ports +* [#4459](https://github.com/netbox-community/netbox/issues/4459) - Fix caching issue resulting in erroneous nested data for regions, rack groups, and tenant groups +* [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view +* [#4496](https://github.com/netbox-community/netbox/issues/4496) - Fix exception when validating certain models via REST API +* [#4510](https://github.com/netbox-community/netbox/issues/4510) - Enforce address family for device primary IPv4/v6 addresses + +--- + +## v2.8.0 (2020-04-13) + +**NOTE:** Beginning with release 2.8.0, NetBox requires Python 3.6 or later. + +### New Features (Beta) + +This releases introduces two new features in beta status. While they are expected to be functional, their precise implementation is subject to change during the v2.8 release cycle. It is recommended to wait until NetBox v2.9 to deploy them in production. + +#### Remote Authentication Support ([#2328](https://github.com/netbox-community/netbox/issues/2328)) + +Several new configuration parameters provide support for authenticating an incoming request based on the value of a specific HTTP header. This can be leveraged to employ remote authentication via an nginx or Apache plugin, directing NetBox to create and configure a local user account as needed. The configuration parameters are: + +* `REMOTE_AUTH_ENABLED` - Enables remote authentication (disabled by default) +* `REMOTE_AUTH_HEADER` - The name of the HTTP header which conveys the username +* `REMOTE_AUTH_AUTO_CREATE_USER` - Enables the automatic creation of new users (disabled by default) +* `REMOTE_AUTH_DEFAULT_GROUPS` - A list of groups to assign newly created users +* `REMOTE_AUTH_DEFAULT_PERMISSIONS` - A list of permissions to assign newly created users + +If further customization of remote authentication is desired (for instance, if you want to pass group/permission information via HTTP headers as well), NetBox allows you to inject a custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to retain full control over the authentication and configuration of remote users. + +#### Plugins ([#3351](https://github.com/netbox-community/netbox/issues/3351)) + +This release introduces support for custom plugins, which can be used to extend NetBox's functionality beyond what the core product provides. For example, plugins can be used to: + +* Add new Django models +* Provide new views with custom templates +* Inject custom template into object views +* Introduce new API endpoints +* Add custom request/response middleware + +For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://netbox.readthedocs.io/en/stable/plugins/). + +### Enhancements + +* [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups +* [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups +* [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models +* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging)) + +### Bug Fixes + +* [#4474](https://github.com/netbox-community/netbox/issues/4474) - Fix population of device types when bulk editing devices +* [#4476](https://github.com/netbox-community/netbox/issues/4476) - Correct typo in slugs for Infiniband interface types + +### API Changes + +* The `_choices` API endpoints have been removed. Instead, use an `OPTIONS` request to a model's endpoint to view the available values for all fields. ([#3416](https://github.com/netbox-community/netbox/issues/3416)) +* The `id__in` filter has been removed from all models ([#4313](https://github.com/netbox-community/netbox/issues/4313)). Use the format `?id=1&id=2` instead. +* dcim.Manufacturer: Added a `description` field +* dcim.Platform: Added a `description` field +* dcim.Rack: The `/api/dcim/racks//units/` endpoint has been replaced with `/api/dcim/racks//elevation/`. +* dcim.RackGroup: Added a `description` field +* dcim.Region: Added a `description` field +* extras.Tag: Renamed `comments` to `description`; truncated length to 200 characters; removed Markdown rendering +* ipam.RIR: Added a `description` field +* ipam.VLANGroup: Added a `description` field +* tenancy.TenantGroup: Added a `description` field +* virtualization.ClusterGroup: Added a `description` field +* virtualization.ClusterType: Added a `description` field + +### Other Changes + +* [#4081](https://github.com/netbox-community/netbox/issues/4081) - The `family` field has been removed from the Aggregate, Prefix, and IPAddress models. The field remains available in the API representations of these models, however the column has been removed from the database table. diff --git a/mkdocs.yml b/mkdocs.yml index 4bc6c955d..b8633ea8f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,11 +7,12 @@ python: theme: name: readthedocs navigation_depth: 3 +extra_css: + - extra.css markdown_extensions: - admonition: - markdown_include.include: headingOffset: 1 - nav: - Introduction: 'index.md' - Installation: @@ -53,6 +54,9 @@ nav: - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' - Webhooks: 'additional-features/webhooks.md' + - Plugins: + - Using Plugins: 'plugins/index.md' + - Developing Plugins: 'plugins/development.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' @@ -67,9 +71,11 @@ nav: - Style Guide: 'development/style-guide.md' - Utility Views: 'development/utility-views.md' - Extending Models: 'development/extending-models.md' + - Application Registry: 'development/application-registry.md' + - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - - Squashing Migrations: 'development/squashing-migrations.md' - Release Notes: + - Version 2.8: 'release-notes/version-2.8.md' - Version 2.7: 'release-notes/version-2.7.md' - Version 2.6: 'release-notes/version-2.6.md' - Version 2.5: 'release-notes/version-2.5.md' diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index cd3015d0a..01fbfb62c 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -14,9 +14,6 @@ class CircuitsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = CircuitsRootView -# Field choices -router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') - # Providers router.register('providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 75f7e0e3e..363392a4d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -8,21 +8,10 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from . import serializers -# -# Field choices -# - -class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.CircuitSerializer, ['status']), - (serializers.CircuitTerminationSerializer, ['term_side']), - ) - - # # Providers # diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 4bd5fa158..206dcc305 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -5,7 +5,7 @@ from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter + BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter ) from .choices import * from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -19,10 +19,6 @@ __all__ = ( class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -55,7 +51,7 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account'] + fields = ['id', 'name', 'slug', 'asn', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -77,10 +73,6 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -137,7 +129,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr class Meta: model = Circuit - fields = ['cid', 'install_date', 'commit_rate'] + fields = ['id', 'cid', 'install_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 0b0378a7a..2185d1eab 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,16 +1,16 @@ from django import forms -from taggit.forms import TagField from dcim.models import Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, + TagField, ) from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, + CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -55,12 +55,6 @@ class ProviderCSVForm(CustomFieldModelCSVForm): class Meta: model = Provider fields = Provider.csv_headers - help_texts = { - 'name': 'Provider name', - 'asn': '32-bit autonomous system number', - 'portal_url': 'Portal URL', - 'comments': 'Free-form comments', - } class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -113,7 +107,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -125,7 +118,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -150,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(forms.ModelForm): +class CircuitTypeCSVForm(CSVModelForm): slug = SlugField() class Meta: @@ -167,16 +159,10 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - widget=APISelect( - api_url="/api/circuits/providers/" - ) + queryset=Provider.objects.all() ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all(), - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + queryset=CircuitType.objects.all() ) comments = CommentField() tags = TagField( @@ -200,35 +186,26 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class CircuitCSVForm(CustomFieldModelCSVForm): - provider = forms.ModelChoiceField( + provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Name of parent provider', - error_messages={ - 'invalid_choice': 'Provider not found.' - } + help_text='Assigned provider' ) - type = forms.ModelChoiceField( + type = CSVModelChoiceField( queryset=CircuitType.objects.all(), to_field_name='name', - help_text='Type of circuit', - error_messages={ - 'invalid_choice': 'Invalid circuit type.' - } + help_text='Type of circuit' ) status = CSVChoiceField( choices=CircuitStatusChoices, required=False, help_text='Operational status' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.' - } + help_text='Assigned tenant' ) class Meta: @@ -245,17 +222,11 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + required=False ) provider = DynamicModelChoiceField( queryset=Provider.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/providers/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(CircuitStatusChoices), @@ -265,10 +236,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) commit_rate = forms.IntegerField( required=False, @@ -303,7 +271,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/circuit-types/", value_field="slug", ) ) @@ -312,7 +279,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/providers/", value_field="slug", ) ) @@ -326,7 +292,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -338,7 +303,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -355,6 +319,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) class Meta: model = CircuitTermination @@ -368,7 +335,4 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), - 'site': APISelect( - api_url="/api/dcim/sites/" - ) } diff --git a/netbox/circuits/migrations/0001_initial_squashed_0006_terminations.py b/netbox/circuits/migrations/0001_initial_squashed_0006_terminations.py deleted file mode 100644 index 4eec30667..000000000 --- a/netbox/circuits/migrations/0001_initial_squashed_0006_terminations.py +++ /dev/null @@ -1,134 +0,0 @@ -import django.db.models.deletion -from django.db import migrations, models - -import dcim.fields - - -def circuits_to_terms(apps, schema_editor): - Circuit = apps.get_model('circuits', 'Circuit') - CircuitTermination = apps.get_model('circuits', 'CircuitTermination') - for c in Circuit.objects.all(): - CircuitTermination( - circuit=c, - term_side=b'A', - site=c.site, - interface=c.interface, - port_speed=c.port_speed, - upstream_speed=c.upstream_speed, - xconnect_id=c.xconnect_id, - pp_info=c.pp_info, - ).save() - - -class Migration(migrations.Migration): - - replaces = [('circuits', '0001_initial'), ('circuits', '0002_auto_20160622_1821'), ('circuits', '0003_provider_32bit_asn_support'), ('circuits', '0004_circuit_add_tenant'), ('circuits', '0005_circuit_add_upstream_speed'), ('circuits', '0006_terminations')] - - dependencies = [ - ('tenancy', '0001_initial'), - ('dcim', '0001_initial'), - ('dcim', '0022_color_names_to_rgb'), - ] - - operations = [ - migrations.CreateModel( - name='CircuitType', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True)), - ('slug', models.SlugField(unique=True)), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Provider', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateField(auto_now_add=True)), - ('last_updated', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50, unique=True)), - ('slug', models.SlugField(unique=True)), - ('asn', dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN')), - ('account', models.CharField(blank=True, max_length=30, verbose_name=b'Account number')), - ('portal_url', models.URLField(blank=True, verbose_name=b'Portal')), - ('noc_contact', models.TextField(blank=True, verbose_name=b'NOC contact')), - ('admin_contact', models.TextField(blank=True, verbose_name=b'Admin contact')), - ('comments', models.TextField(blank=True)), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Circuit', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateField(auto_now_add=True)), - ('last_updated', models.DateTimeField(auto_now=True)), - ('cid', models.CharField(max_length=50, verbose_name=b'Circuit ID')), - ('install_date', models.DateField(blank=True, null=True, verbose_name=b'Date installed')), - ('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')), - ('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'Commit rate (Kbps)')), - ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')), - ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')), - ('comments', models.TextField(blank=True)), - ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='circuit', to='dcim.Interface')), - ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='dcim.Site')), - ('type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType')), - ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant')), - ('upstream_speed', models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)')), - ], - options={ - 'ordering': ['provider', 'cid'], - 'unique_together': {('provider', 'cid')}, - }, - ), - migrations.CreateModel( - name='CircuitTermination', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1, verbose_name='Termination')), - ('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')), - ('upstream_speed', models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)')), - ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')), - ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')), - ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.Circuit')), - ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='circuit_termination', to='dcim.Interface')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.Site')), - ], - options={ - 'ordering': ['circuit', 'term_side'], - 'unique_together': {('circuit', 'term_side')}, - }, - ), - migrations.RunPython( - code=circuits_to_terms, - ), - migrations.RemoveField( - model_name='circuit', - name='interface', - ), - migrations.RemoveField( - model_name='circuit', - name='port_speed', - ), - migrations.RemoveField( - model_name='circuit', - name='pp_info', - ), - migrations.RemoveField( - model_name='circuit', - name='site', - ), - migrations.RemoveField( - model_name='circuit', - name='upstream_speed', - ), - migrations.RemoveField( - model_name='circuit', - name='xconnect_id', - ), - ] diff --git a/netbox/circuits/migrations/0007_circuit_add_description_squashed_0017_circuittype_description.py b/netbox/circuits/migrations/0007_circuit_add_description_squashed_0017_circuittype_description.py deleted file mode 100644 index 5bcd863a4..000000000 --- a/netbox/circuits/migrations/0007_circuit_add_description_squashed_0017_circuittype_description.py +++ /dev/null @@ -1,254 +0,0 @@ -import sys - -import django.db.models.deletion -import taggit.managers -from django.db import migrations, models - -import dcim.fields - -CONNECTION_STATUS_CONNECTED = True - -CIRCUIT_STATUS_CHOICES = ( - (0, 'deprovisioning'), - (1, 'active'), - (2, 'planned'), - (3, 'provisioning'), - (4, 'offline'), - (5, 'decommissioned') -) - - -def circuit_terminations_to_cables(apps, schema_editor): - """ - Copy all existing CircuitTermination Interface associations as Cables - """ - ContentType = apps.get_model('contenttypes', 'ContentType') - CircuitTermination = apps.get_model('circuits', 'CircuitTermination') - Interface = apps.get_model('dcim', 'Interface') - Cable = apps.get_model('dcim', 'Cable') - - # Load content types - circuittermination_type = ContentType.objects.get_for_model(CircuitTermination) - interface_type = ContentType.objects.get_for_model(Interface) - - # Create a new Cable instance from each console connection - if 'test' not in sys.argv: - print("\n Adding circuit terminations... ", end='', flush=True) - for circuittermination in CircuitTermination.objects.filter(interface__isnull=False): - - # Create the new Cable - cable = Cable.objects.create( - termination_a_type=circuittermination_type, - termination_a_id=circuittermination.id, - termination_b_type=interface_type, - termination_b_id=circuittermination.interface_id, - status=CONNECTION_STATUS_CONNECTED - ) - - # Cache the Cable on its two termination points - CircuitTermination.objects.filter(pk=circuittermination.pk).update( - cable=cable, - connected_endpoint=circuittermination.interface, - connection_status=CONNECTION_STATUS_CONNECTED - ) - # Cache the connected Cable on the Interface - Interface.objects.filter(pk=circuittermination.interface_id).update( - cable=cable, - _connected_circuittermination=circuittermination, - connection_status=CONNECTION_STATUS_CONNECTED - ) - - cable_count = Cable.objects.filter(termination_a_type=circuittermination_type).count() - if 'test' not in sys.argv: - print("{} cables created".format(cable_count)) - - -def circuit_status_to_slug(apps, schema_editor): - Circuit = apps.get_model('circuits', 'Circuit') - for id, slug in CIRCUIT_STATUS_CHOICES: - Circuit.objects.filter(status=str(id)).update(status=slug) - - -class Migration(migrations.Migration): - - replaces = [('circuits', '0007_circuit_add_description'), ('circuits', '0008_circuittermination_interface_protect_on_delete'), ('circuits', '0009_unicode_literals'), ('circuits', '0010_circuit_status'), ('circuits', '0011_tags'), ('circuits', '0012_change_logging'), ('circuits', '0013_cables'), ('circuits', '0014_circuittermination_description'), ('circuits', '0015_custom_tag_models'), ('circuits', '0016_3569_circuit_fields'), ('circuits', '0017_circuittype_description')] - - dependencies = [ - ('circuits', '0006_terminations'), - ('extras', '0019_tag_taggeditem'), - ('taggit', '0002_auto_20150616_2121'), - ('dcim', '0066_cables'), - ] - - operations = [ - migrations.AddField( - model_name='circuit', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name='circuittermination', - name='interface', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'), - ), - migrations.AlterField( - model_name='circuit', - name='cid', - field=models.CharField(max_length=50, verbose_name='Circuit ID'), - ), - migrations.AlterField( - model_name='circuit', - name='commit_rate', - field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)'), - ), - migrations.AlterField( - model_name='circuit', - name='install_date', - field=models.DateField(blank=True, null=True, verbose_name='Date installed'), - ), - migrations.AlterField( - model_name='circuittermination', - name='port_speed', - field=models.PositiveIntegerField(verbose_name='Port speed (Kbps)'), - ), - migrations.AlterField( - model_name='circuittermination', - name='pp_info', - field=models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)'), - ), - migrations.AlterField( - model_name='circuittermination', - name='term_side', - field=models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination'), - ), - migrations.AlterField( - model_name='circuittermination', - name='upstream_speed', - field=models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)'), - ), - migrations.AlterField( - model_name='circuittermination', - name='xconnect_id', - field=models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID'), - ), - migrations.AlterField( - model_name='provider', - name='account', - field=models.CharField(blank=True, max_length=30, verbose_name='Account number'), - ), - migrations.AlterField( - model_name='provider', - name='admin_contact', - field=models.TextField(blank=True, verbose_name='Admin contact'), - ), - migrations.AlterField( - model_name='provider', - name='asn', - field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'), - ), - migrations.AlterField( - model_name='provider', - name='noc_contact', - field=models.TextField(blank=True, verbose_name='NOC contact'), - ), - migrations.AlterField( - model_name='provider', - name='portal_url', - field=models.URLField(blank=True, verbose_name='Portal'), - ), - migrations.AddField( - model_name='circuit', - name='status', - field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1), - ), - migrations.AddField( - model_name='circuit', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), - ), - migrations.AddField( - model_name='provider', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), - ), - migrations.AddField( - model_name='circuittype', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='circuittype', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AlterField( - model_name='circuit', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='circuit', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AlterField( - model_name='provider', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='provider', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='circuittermination', - name='connected_endpoint', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'), - ), - migrations.AddField( - model_name='circuittermination', - name='connection_status', - field=models.NullBooleanField(), - ), - migrations.AddField( - model_name='circuittermination', - name='cable', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), - ), - migrations.RunPython( - code=circuit_terminations_to_cables, - ), - migrations.RemoveField( - model_name='circuittermination', - name='interface', - ), - migrations.AddField( - model_name='circuittermination', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name='circuit', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'), - ), - migrations.AlterField( - model_name='provider', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'), - ), - migrations.AlterField( - model_name='circuit', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=circuit_status_to_slug, - ), - migrations.AddField( - model_name='circuittype', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - ] diff --git a/netbox/circuits/migrations/0018_standardize_description.py b/netbox/circuits/migrations/0018_standardize_description.py new file mode 100644 index 000000000..a0a213e17 --- /dev/null +++ b/netbox/circuits/migrations/0018_standardize_description.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0017_circuittype_description'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='circuittermination', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='circuittype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..57d41a994 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -21,6 +22,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -36,7 +38,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel): asn = ASNField( blank=True, null=True, - verbose_name='ASN' + verbose_name='ASN', + help_text='32-bit autonomous system number' ) account = models.CharField( max_length=30, @@ -45,7 +48,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) portal_url = models.URLField( blank=True, - verbose_name='Portal' + verbose_name='Portal URL' ) noc_contact = models.TextField( blank=True, @@ -108,7 +111,7 @@ class CircuitType(ChangeLoggedModel): unique=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True, ) @@ -131,6 +134,7 @@ class CircuitType(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple @@ -173,7 +177,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): null=True, verbose_name='Commit rate (Kbps)') description = models.CharField( - max_length=100, + max_length=200, blank=True ) comments = models.TextField( @@ -292,7 +296,7 @@ class CircuitTermination(CableTermination): verbose_name='Patch panel/port(s)' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index a425b3ace..ea17031a1 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, TagColumn, ToggleColumn from .models import Circuit, CircuitType, Provider CIRCUITTYPE_ACTIONS = """ @@ -27,18 +27,20 @@ STATUS_LABEL = """ class ProviderTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + circuit_count = tables.Column( + accessor=Accessor('count_circuits'), + verbose_name='Circuits' + ) + tags = TagColumn( + url_name='circuits:provider_list' + ) class Meta(BaseTable.Meta): model = Provider - fields = ('pk', 'name', 'asn', 'account',) - - -class ProviderDetailTable(ProviderTable): - circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') - - class Meta(ProviderTable.Meta): - model = Provider - fields = ('pk', 'name', 'asn', 'account', 'circuit_count') + fields = ( + 'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags', + ) + default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') # @@ -48,7 +50,9 @@ class ProviderDetailTable(ProviderTable): class CircuitTypeTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - circuit_count = tables.Column(verbose_name='Circuits') + circuit_count = tables.Column( + verbose_name='Circuits' + ) actions = tables.TemplateColumn( template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, @@ -58,6 +62,7 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): model = CircuitType fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') # @@ -66,17 +71,33 @@ class CircuitTypeTable(BaseTable): class CircuitTable(BaseTable): pk = ToggleColumn() - cid = tables.LinkColumn(verbose_name='ID') - provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) - status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') - tenant = tables.TemplateColumn(template_code=COL_TENANT) + cid = tables.LinkColumn( + verbose_name='ID' + ) + provider = tables.LinkColumn( + viewname='circuits:provider', + args=[Accessor('provider.slug')] + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) a_side = tables.Column( verbose_name='A Side' ) z_side = tables.Column( verbose_name='Z Side' ) + tags = TagColumn( + url_name='circuits:circuit_list' + ) class Meta(BaseTable.Meta): model = Circuit - fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') + fields = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', + 'description', 'tags', + ) + default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index b1b6d9e14..b5f8758e7 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -6,7 +6,7 @@ from circuits.choices import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.models import Graph -from utilities.testing import APITestCase, choices_to_dict +from utilities.testing import APITestCase class AppTest(APITestCase): @@ -18,19 +18,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('circuits-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # Circuit - self.assertEqual(choices_to_dict(response.data.get('circuit:status')), CircuitStatusChoices.as_dict()) - - # CircuitTermination - self.assertEqual(choices_to_dict(response.data.get('circuit-termination:term_side')), CircuitTerminationSideChoices.as_dict()) - class ProviderTest(APITestCase): diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 63681899a..9756c320b 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -54,6 +54,10 @@ class ProviderTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000), )) + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): params = {'name': ['Provider 1', 'Provider 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -70,11 +74,6 @@ class ProviderTestCase(TestCase): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_id__in(self): - id_list = self.queryset.values_list('id', flat=True)[:3] - params = {'id__in': ','.join([str(id) for id in id_list])} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -144,7 +143,8 @@ class CircuitTestCase(TestCase): TenantGroup(name='Tenant group 2', slug='tenant-group-2'), TenantGroup(name='Tenant group 3', slug='tenant-group-3'), ) - TenantGroup.objects.bulk_create(tenant_groups) + for tenantgroup in tenant_groups: + tenantgroup.save() tenants = ( Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), @@ -182,6 +182,10 @@ class CircuitTestCase(TestCase): )) CircuitTermination.objects.bulk_create(circuit_terminations) + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cid(self): params = {'cid': ['Test Circuit 1', 'Test Circuit 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -194,11 +198,6 @@ class CircuitTestCase(TestCase): params = {'commit_rate': ['1000', '2000']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_id__in(self): - id_list = self.queryset.values_list('id', flat=True)[:3] - params = {'id__in': ','.join([str(id) for id in id_list])} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - def test_provider(self): provider = Provider.objects.first() params = {'provider_id': [provider.pk]} diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b092e1855..709d2a726 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -28,7 +28,7 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm - table = tables.ProviderDetailTable + table = tables.ProviderTable class ProviderView(PermissionRequiredMixin, View): @@ -87,7 +87,7 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_provider' - queryset = Provider.objects.all() + queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable form = forms.ProviderBulkEditForm @@ -96,7 +96,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' - queryset = Provider.objects.all() + queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable default_return_url = 'circuits:provider_list' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f2d2fdb00..11c1f5051 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -64,7 +64,7 @@ class RegionSerializer(CustomFieldModelSerializer): class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent', 'site_count', 'custom_fields'] + fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count', 'custom_fields'] class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -96,11 +96,12 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() + parent = NestedRackGroupSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site', 'rack_count'] + fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count'] class RackRoleSerializer(ValidatedModelSerializer): @@ -142,8 +143,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. if data.get('facility_id', None): validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) @@ -218,7 +218,9 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count'] + fields = [ + 'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + ] class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -355,7 +357,7 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count', + 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', 'virtualmachine_count', ] @@ -392,8 +394,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta. if data.get('rack') and data.get('position') and data.get('face'): validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face')) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) @@ -530,6 +531,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): ) cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) + count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: model = Interface diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 5a915becc..f989d817c 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -14,9 +14,6 @@ class DCIMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = DCIMRootView -# Field choices -router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') - # Sites router.register('regions', views.RegionViewSet) router.register('sites', views.SiteViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2a000ac24..10f31b1eb 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -26,7 +26,7 @@ from extras.api.views import CustomFieldModelViewSet from extras.models import Graph from ipam.models import Prefix, VLAN from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, + get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery from virtualization.models import VirtualMachine @@ -34,35 +34,6 @@ from . import serializers from .exceptions import MissingFilterException -# -# Field choices -# - -class DCIMFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.CableSerializer, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']), - (serializers.ConsolePortSerializer, ['type', 'connection_status']), - (serializers.ConsolePortTemplateSerializer, ['type']), - (serializers.ConsoleServerPortSerializer, ['type']), - (serializers.ConsoleServerPortTemplateSerializer, ['type']), - (serializers.DeviceSerializer, ['face', 'status']), - (serializers.DeviceTypeSerializer, ['subdevice_role']), - (serializers.FrontPortSerializer, ['type']), - (serializers.FrontPortTemplateSerializer, ['type']), - (serializers.InterfaceSerializer, ['type', 'mode']), - (serializers.InterfaceTemplateSerializer, ['type']), - (serializers.PowerFeedSerializer, ['phase', 'status', 'supply', 'type']), - (serializers.PowerOutletSerializer, ['type', 'feed_leg']), - (serializers.PowerOutletTemplateSerializer, ['type', 'feed_leg']), - (serializers.PowerPortSerializer, ['type', 'connection_status']), - (serializers.PowerPortTemplateSerializer, ['type']), - (serializers.RackSerializer, ['outer_unit', 'status', 'type', 'width']), - (serializers.RearPortSerializer, ['type']), - (serializers.RearPortTemplateSerializer, ['type']), - (serializers.SiteSerializer, ['status']), - ) - - # Mixins class CableTraceMixin(object): @@ -77,7 +48,7 @@ class CableTraceMixin(object): # Initialize the path array path = [] - for near_end, cable, far_end in obj.trace(follow_circuits=True): + for near_end, cable, far_end in obj.trace()[0]: # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') @@ -176,33 +147,6 @@ class RackViewSet(CustomFieldModelViewSet): serializer_class = serializers.RackSerializer filterset_class = filters.RackFilterSet - @swagger_auto_schema(deprecated=True) - @action(detail=True) - def units(self, request, pk=None): - """ - List rack units (by rack) - """ - # TODO: Remove this action detail route in v2.8 - rack = get_object_or_404(Rack, pk=pk) - face = request.GET.get('face', 'front') - exclude_pk = request.GET.get('exclude', None) - if exclude_pk is not None: - try: - exclude_pk = int(exclude_pk) - except ValueError: - exclude_pk = None - elevation = rack.get_rack_units(face, exclude_pk) - - # Enable filtering rack units by ID - q = request.GET.get('q', None) - if q: - elevation = [u for u in elevation if q in str(u['id'])] - - page = self.paginate_queryset(elevation) - if page is not None: - rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) - return self.get_paginated_response(rack_units.data) - @swagger_auto_schema( responses={200: serializers.RackUnitSerializer(many=True)}, query_serializer=serializers.RackElevationDetailFilterSerializer @@ -225,7 +169,8 @@ class RackViewSet(CustomFieldModelViewSet): unit_width=data['unit_width'], unit_height=data['unit_height'], legend_width=data['legend_width'], - include_images=data['include_images'] + include_images=data['include_images'], + base_url=request.build_absolute_uri('/') ) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 007b1a967..e79222449 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -63,11 +63,13 @@ class RackWidthChoices(ChoiceSet): WIDTH_10IN = 10 WIDTH_19IN = 19 + WIDTH_21IN = 21 WIDTH_23IN = 23 CHOICES = ( (WIDTH_10IN, '10 inches'), (WIDTH_19IN, '19 inches'), + (WIDTH_21IN, '21 inches'), (WIDTH_23IN, '23 inches'), ) @@ -280,6 +282,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L650P = 'nema-l6-50p' + TYPE_NEMA_L1420P = 'nema-l14-20p' + TYPE_NEMA_L1430P = 'nema-l14-30p' + TYPE_NEMA_L2120P = 'nema-l21-20p' + TYPE_NEMA_L2130P = 'nema-l21-30p' # California style TYPE_CS6361C = 'cs6361c' TYPE_CS6365C = 'cs6365c' @@ -341,6 +347,10 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'), + (TYPE_NEMA_L1420P, 'NEMA L14-20P'), + (TYPE_NEMA_L1430P, 'NEMA L14-30P'), + (TYPE_NEMA_L2120P, 'NEMA L21-20P'), + (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), ('California Style', ( (TYPE_CS6361C, 'CS6361C'), @@ -409,6 +419,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L650R = 'nema-l6-50r' + TYPE_NEMA_L1420R = 'nema-l14-20r' + TYPE_NEMA_L1430R = 'nema-l14-30r' + TYPE_NEMA_L2120R = 'nema-l21-20r' + TYPE_NEMA_L2130R = 'nema-l21-30r' # California style TYPE_CS6360C = 'CS6360C' TYPE_CS6364C = 'CS6364C' @@ -428,6 +442,8 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_ITA_M = 'ita-m' TYPE_ITA_N = 'ita-n' TYPE_ITA_O = 'ita-o' + # Proprietary + TYPE_HDOT_CX = 'hdot-cx' CHOICES = ( ('IEC 60320', ( @@ -469,6 +485,10 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'), + (TYPE_NEMA_L1420R, 'NEMA L14-20R'), + (TYPE_NEMA_L1430R, 'NEMA L14-30R'), + (TYPE_NEMA_L2120R, 'NEMA L21-20R'), + (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), ('California Style', ( (TYPE_CS6360C, 'CS6360C'), @@ -491,6 +511,9 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_ITA_N, 'ITA Type N'), (TYPE_ITA_O, 'ITA Type O'), )), + ('Proprietary', ( + (TYPE_HDOT_CX, 'HDOT Cx'), + )), ) @@ -581,15 +604,15 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_128GFC_QSFP28 = '128gfc-sfp28' # InfiniBand - TYPE_INFINIBAND_SDR = 'inifiband-sdr' - TYPE_INFINIBAND_DDR = 'inifiband-ddr' - TYPE_INFINIBAND_QDR = 'inifiband-qdr' - TYPE_INFINIBAND_FDR10 = 'inifiband-fdr10' - TYPE_INFINIBAND_FDR = 'inifiband-fdr' - TYPE_INFINIBAND_EDR = 'inifiband-edr' - TYPE_INFINIBAND_HDR = 'inifiband-hdr' - TYPE_INFINIBAND_NDR = 'inifiband-ndr' - TYPE_INFINIBAND_XDR = 'inifiband-xdr' + TYPE_INFINIBAND_SDR = 'infiniband-sdr' + TYPE_INFINIBAND_DDR = 'infiniband-ddr' + TYPE_INFINIBAND_QDR = 'infiniband-qdr' + TYPE_INFINIBAND_FDR10 = 'infiniband-fdr10' + TYPE_INFINIBAND_FDR = 'infiniband-fdr' + TYPE_INFINIBAND_EDR = 'infiniband-edr' + TYPE_INFINIBAND_HDR = 'infiniband-hdr' + TYPE_INFINIBAND_NDR = 'infiniband-ndr' + TYPE_INFINIBAND_XDR = 'infiniband-xdr' # Serial TYPE_T1 = 't1' diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 78a418283..f938b6f14 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -92,5 +92,5 @@ COMPATIBLE_TERMINATION_TYPES = { 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], - 'circuittermination': ['interface', 'frontport', 'rearport'], + 'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'], } diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index a1af3968c..ea780b2d9 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/elevations.py @@ -15,10 +15,15 @@ class RackElevationSVG: :param rack: A NetBox Rack instance :param include_images: If true, the SVG document will embed front/rear device face images, where available + :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, include_images=True): + def __init__(self, rack, include_images=True, base_url=None): self.rack = rack self.include_images = include_images + if base_url is not None: + self.base_url = base_url.rstrip('/') + else: + self.base_url = '' def _get_device_description(self, device): return '{} ({}) — {} ({}U) {} {}'.format( @@ -69,7 +74,7 @@ class RackElevationSVG: color = device.device_role.color link = drawing.add( drawing.a( - href=reverse('dcim:device', kwargs={'pk': device.pk}), + href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), target='_top', fill='black' ) @@ -81,7 +86,7 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: - url = device.device_type.front_image.url + url = '{}{}'.format(self.base_url, device.device_type.front_image.url) image = drawing.image(href=url, insert=start, size=end, class_='device-image') image.fit(scale='slice') link.add(image) diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py index e788c9b5f..18e42318b 100644 --- a/netbox/dcim/exceptions.py +++ b/netbox/dcim/exceptions.py @@ -3,3 +3,12 @@ class LoopDetected(Exception): A loop has been detected while tracing a cable path. """ pass + + +class CableTraceSplit(Exception): + """ + A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and + we don't know which one to follow. + """ + def __init__(self, termination, *args, **kwargs): + self.termination = termination diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index ff201c3fd..39d684d55 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -4,10 +4,10 @@ from django.contrib.auth.models import User from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant -from utilities.constants import COLOR_CHOICES +from utilities.choices import ColorChoices from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, - NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * @@ -74,14 +74,10 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldFilterS class Meta: model = Region - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -157,10 +153,20 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Site (slug)', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=RackGroup.objects.all(), + to_field_name='slug', + label='Rack group (slug)', + ) class Meta: model = RackGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): @@ -171,10 +177,6 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -202,15 +204,18 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = TreeNodeMultipleChoiceFilter( queryset=RackGroup.objects.all(), - label='Group (ID)', + field_name='group', + lookup_expr='in', + label='Rack group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( - field_name='group__slug', + group = TreeNodeMultipleChoiceFilter( queryset=RackGroup.objects.all(), + field_name='group', + lookup_expr='in', to_field_name='slug', - label='Group', + label='Rack group (slug)', ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, @@ -251,10 +256,6 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -274,16 +275,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = TreeNodeMultipleChoiceFilter( + queryset=RackGroup.objects.all(), field_name='rack__group', - queryset=RackGroup.objects.all(), - label='Group (ID)', + lookup_expr='in', + label='Rack group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( - field_name='rack__group__slug', + group = TreeNodeMultipleChoiceFilter( queryset=RackGroup.objects.all(), + field_name='rack__group', + lookup_expr='in', to_field_name='slug', - label='Group', + label='Rack group (slug)', ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), @@ -298,7 +301,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): class Meta: model = RackReservation - fields = ['created'] + fields = ['id', 'created'] def search(self, queryset, name, value): if not value.strip(): @@ -315,14 +318,10 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Manufacturer - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -370,7 +369,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil class Meta: model = DeviceType fields = [ - 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', ] def search(self, queryset, name, value): @@ -494,7 +493,7 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'napalm_driver'] + fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] class DeviceFilterSet( @@ -504,10 +503,6 @@ class DeviceFilterSet( CustomFieldFilterSet, CreatedUpdatedFilterSet ): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -571,9 +566,10 @@ class DeviceFilterSet( to_field_name='slug', label='Site name (slug)', ) - rack_group_id = django_filters.ModelMultipleChoiceFilter( - field_name='rack__group', + rack_group_id = TreeNodeMultipleChoiceFilter( queryset=RackGroup.objects.all(), + field_name='rack__group', + lookup_expr='in', label='Rack group (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( @@ -1088,7 +1084,7 @@ class CableFilterSet(BaseFilterSet): choices=CableStatusChoices ) color = django_filters.MultipleChoiceFilter( - choices=COLOR_CHOICES + choices=ColorChoices ) device_id = MultiValueNumberFilter( method='filter_device' @@ -1236,10 +1232,6 @@ class InterfaceConnectionFilterSet(BaseFilterSet): class PowerPanelFilterSet(BaseFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -1267,15 +1259,16 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Site name (slug)', ) - rack_group_id = django_filters.ModelMultipleChoiceFilter( - field_name='rack_group', + rack_group_id = TreeNodeMultipleChoiceFilter( queryset=RackGroup.objects.all(), + field_name='rack_group', + lookup_expr='in', label='Rack group (ID)', ) class Meta: model = PowerPanel - fields = ['name'] + fields = ['id', 'name'] def search(self, queryset, name, value): if not value.strip(): @@ -1287,10 +1280,6 @@ class PowerPanelFilterSet(BaseFilterSet): class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -1332,7 +1321,7 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt class Meta: model = PowerFeed - fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'] + fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c11f5906d..f421c2e81 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,16 +5,16 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist +from django.utils.safestring import mark_safe from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError -from taggit.forms import TagField from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, + LocalConfigContextFilterForm, TagField, ) from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN @@ -22,9 +22,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, - SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, + CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, + JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -71,7 +72,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field='slug', filter_for={ 'site': 'region' @@ -83,7 +83,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -93,10 +92,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -192,28 +188,21 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', + 'parent', 'name', 'slug', 'description', ) -class RegionCSVForm(forms.ModelForm): - parent = forms.ModelChoiceField( +class RegionCSVForm(CSVModelForm): + parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of parent region', - error_messages={ - 'invalid_choice': 'Region not found.', - } + help_text='Name of parent region' ) class Meta: model = Region fields = Region.csv_headers - help_texts = { - 'name': 'Region name', - 'slug': 'URL-friendly slug', - } class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -280,32 +269,26 @@ class SiteCSVForm(CustomFieldModelCSVForm): required=False, help_text='Operational status' ) - region = forms.ModelChoiceField( + region = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned region', - error_messages={ - 'invalid_choice': 'Region not found.', - } + help_text='Assigned region' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) class Meta: model = Site fields = Site.csv_headers help_texts = { - 'name': 'Site name', - 'slug': 'URL-friendly slug', - 'asn': '32-bit autonomous system number', + 'time_zone': mark_safe( + 'Time zone (available options)' + ) } @@ -327,10 +310,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) asn = forms.IntegerField( min_value=BGP_ASN_MIN, @@ -371,7 +351,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", ) ) @@ -384,37 +363,40 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( - queryset=Site.objects.all(), - widget=APISelect( - api_url="/api/dcim/sites/" - ) + queryset=Site.objects.all() + ) + parent = DynamicModelChoiceField( + queryset=RackGroup.objects.all(), + required=False ) slug = SlugField() class Meta: model = RackGroup fields = ( - 'site', 'name', 'slug', + 'site', 'parent', 'name', 'slug', 'description', ) -class RackGroupCSVForm(forms.ModelForm): - site = forms.ModelChoiceField( +class RackGroupCSVForm(CSVModelForm): + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', + help_text='Assigned site' + ) + parent = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent rack group', error_messages={ - 'invalid_choice': 'Site not found.', + 'invalid_choice': 'Rack group not found.', } ) class Meta: model = RackGroup fields = RackGroup.csv_headers - help_texts = { - 'name': 'Name of rack group', - 'slug': 'URL-friendly slug', - } class RackGroupFilterForm(BootstrapMixin, forms.Form): @@ -423,10 +405,10 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ - 'site': 'region' + 'site': 'region', + 'parent': 'region', } ) ) @@ -435,7 +417,18 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'parent': 'site', + } + ) + ) + parent = DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", value_field="slug", ) ) @@ -455,15 +448,14 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(forms.ModelForm): +class RackRoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = RackRole fields = RackRole.csv_headers help_texts = { - 'name': 'Name of rack role', - 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -475,7 +467,6 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'group': 'site_id', } @@ -483,17 +474,11 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-groups/', - ) + required=False ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-roles/', - ) + required=False ) comments = CommentField() tags = TagField( @@ -521,40 +506,31 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackCSVForm(CustomFieldModelCSVForm): - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), - to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + to_field_name='name' ) - group_name = forms.CharField( - help_text='Name of rack group', - required=False + group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + to_field_name='name' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Name of assigned tenant' ) status = CSVChoiceField( choices=RackStatusChoices, required=False, help_text='Operational status' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned role', - error_messages={ - 'invalid_choice': 'Role not found.', - } + help_text='Name of assigned role' ) type = CSVChoiceField( choices=RackTypeChoices, @@ -574,38 +550,15 @@ class RackCSVForm(CustomFieldModelCSVForm): class Meta: model = Rack fields = Rack.csv_headers - help_texts = { - 'name': 'Rack name', - 'u_height': 'Height in rack units', - } - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - group_name = self.cleaned_data.get('group_name') - name = self.cleaned_data.get('name') - facility_id = self.cleaned_data.get('facility_id') - - # Validate rack group - if group_name: - try: - self.instance.group = RackGroup.objects.get(site=site, name=group_name) - except RackGroup.DoesNotExist: - raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) - - # Validate uniqueness of rack name within group - if Rack.objects.filter(group=self.instance.group, name=name).exists(): - raise forms.ValidationError( - "A rack named {} already exists within group {}".format(name, group_name) - ) - - # Validate uniqueness of facility ID within group - if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists(): - raise forms.ValidationError( - "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name) - ) + # Limit group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -617,7 +570,6 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites", filter_for={ 'group': 'site_id', } @@ -625,17 +577,11 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-groups", - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(RackStatusChoices), @@ -645,10 +591,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-roles", - ) + required=False ) serial = forms.CharField( max_length=50, @@ -714,7 +657,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -726,7 +668,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'group_id': 'site' @@ -740,7 +681,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Rack group', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", null_option=True ) ) @@ -754,7 +694,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/rack-roles/", value_field="slug", null_option=True, ) @@ -773,7 +712,6 @@ class RackElevationFilterForm(RackFilterForm): label='Rack', required=False, widget=APISelectMultiple( - api_url='/api/dcim/racks/', display_field='display_name', ) ) @@ -791,6 +729,13 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=forms.HiddenInput() + ) + # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain + # the multi-line '); - $('#id_tags').select2({ + $('#id_tags.tagfield').replaceWith(''); + $('#id_tags.tagfield').select2({ tags: true, data: tag_objs, multiple: true, @@ -354,14 +354,14 @@ $(document).ready(function() { } } }); - $('#id_tags').closest('form').submit(function(event){ + $('#id_tags.tagfield').closest('form').submit(function(event){ // django-taggit can only accept a single comma seperated string value - var value = $('#id_tags').val(); + var value = $('#id_tags.tagfield').val(); if (value.length > 0){ var final_tags = value.join(', '); - $('#id_tags').val(null).trigger('change'); + $('#id_tags.tagfield').val(null).trigger('change'); var option = new Option(final_tags, final_tags, true, true); - $('#id_tags').append(option).trigger('change'); + $('#id_tags.tagfield').append(option).trigger('change'); } }); @@ -448,4 +448,33 @@ $(document).ready(function() { $('a.image-preview').on('mouseout', function() { $('#image-preview-window').fadeOut('fast'); }); + + // Rearrange options within a + + + + + + + +
+ {% plugin_buttons rackreservation %} + {% if perms.dcim.change_rackreservation %} + {% edit_button rackreservation %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button rackreservation %} + {% endif %} +
+

{% block title %}{{ rackreservation }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=rackreservation %} +
+ {% custom_links rackreservation %} +
+ +{% endblock %} + +{% block content %} +
+
+
+
+ Rack +
+ + {% with rack=rackreservation.rack %} + + + + + + + + + + + + + {% endwith %} +
Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} + {{ rack.site }} +
Group + {% if rack.group %} + {{ rack.group }} + {% else %} + None + {% endif %} +
Rack + {{ rack }} +
+
+
+
+ Reservation Details +
+ + + + + + + + + + + + + + + + + +
Units{{ rackreservation.unit_list }}
Tenant + {% if rackreservation.tenant %} + {% if rackreservation.tenant.group %} + {{ rackreservation.tenant.group }} + + {% endif %} + {{ rackreservation.tenant }} + {% else %} + None + {% endif %} +
User{{ rackreservation.user }}
Description{{ rackreservation.description }}
+
+ {% plugin_left_page rackreservation %} +
+
+ {% with rack=rackreservation.rack %} +
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with face='front' %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
+
+ {% endwith %} + {% plugin_right_page rackreservation %} +
+
+
+
+ {% plugin_full_width_page rackreservation %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html new file mode 100644 index 000000000..b2304974e --- /dev/null +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -0,0 +1,21 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
{{ obj_type|capfirst }}
+
+
+ +
+

{{ obj.rack }}

+
+
+ {% render_field form.units %} + {% render_field form.user %} + {% render_field form.tenant_group %} + {% render_field form.tenant %} + {% render_field form.description %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 9f842bf10..f5823f721 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,7 +1,8 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% load static %} {% load tz %} @@ -33,6 +34,7 @@
+ {% plugin_buttons site %} {% if show_graphs %}
+ {% plugin_left_page site %}
@@ -286,9 +289,15 @@
{% endif %}
+ {% plugin_right_page site %} {% include 'inc/modal.html' with name='graphs' title='Graphs' %} +
+
+ {% plugin_full_width_page site %} +
+
{% endblock %} {% block javascript %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html new file mode 100644 index 000000000..a97c42e4f --- /dev/null +++ b/netbox/templates/dcim/virtualchassis.html @@ -0,0 +1,111 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% plugin_buttons virtualchassis %} + {% if perms.dcim.change_virtualchassis %} + {% edit_button virtualchassis %} + {% endif %} + {% if perms.dcim.delete_virtualchassis %} + {% delete_button virtualchassis %} + {% endif %} +
+

{% block title %}{{ virtualchassis }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=virtualchassis %} +
+ {% custom_links virtualchassis %} +
+ +{% endblock %} + +{% block content %} +
+
+
+
+ Virtual Chassis +
+ + + + + +
Domain{{ virtualchassis.domain|placeholder }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} + {% plugin_left_page virtualchassis %} +
+
+
+
+ Members +
+ + + + + + + + {% for vc_member in virtualchassis.members.all %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vc_member }} + {{ vc_member.vc_position }}{% if virtualchassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|placeholder }}
+ {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
+ {% plugin_right_page virtualchassis %} +
+
+
+
+ {% plugin_full_width_page virtualchassis %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add_member.html b/netbox/templates/dcim/virtualchassis_add_member.html index cef1a2a2e..5b3a92208 100644 --- a/netbox/templates/dcim/virtualchassis_add_member.html +++ b/netbox/templates/dcim/virtualchassis_add_member.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 2665473fc..54bdc9fe8 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html index 6f10c2e27..48ab707b7 100644 --- a/netbox/templates/exceptions/programming_error.html +++ b/netbox/templates/exceptions/programming_error.html @@ -10,7 +10,7 @@ python3 manage.py migrate from the command line.

- Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.4 or higher is in use. You + Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You can check this by connecting to the database using NetBox's credentials and issuing a query for SELECT VERSION().

diff --git a/netbox/templates/extras/admin/plugins_list.html b/netbox/templates/extras/admin/plugins_list.html new file mode 100644 index 000000000..3e8c166dc --- /dev/null +++ b/netbox/templates/extras/admin/plugins_list.html @@ -0,0 +1,57 @@ +{% extends "admin/base_site.html" %} + +{% block title %}Installed Plugins {{ block.super }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content_title %}

Installed Plugins{{ queue.name }}

{% endblock %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + {% for plugin in plugins %} + + + + + + + + + {% endfor %} + +
Name
Package Name
Author
Author Email
Description
Version
+ {{ plugin.verbose_name }} + + {{ plugin.name }} + + {{ plugin.author }} + + {{ plugin.author_email }} + + {{ plugin.description }} + + {{ plugin.version }} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 7731acae5..21e8cdab6 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load static %} @@ -215,13 +215,9 @@ {% include 'extras/inc/configcontext_format.html' %}
- {% include 'extras/inc/configcontext_data.html' with data=configcontext.data %} + {% include 'extras/inc/configcontext_data.html' with data=configcontext.data format=format %}
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/extras/inc/configcontext_data.html b/netbox/templates/extras/inc/configcontext_data.html index d91960e2c..085887748 100644 --- a/netbox/templates/extras/inc/configcontext_data.html +++ b/netbox/templates/extras/inc/configcontext_data.html @@ -1,8 +1,5 @@ {% load helpers %} -
-
{{ data|render_json }}
-
-
- {% include 'extras/inc/configcontext_data.html' with data=rendered_context %} + {% include 'extras/inc/configcontext_data.html' with data=rendered_context format=format %}
@@ -24,7 +24,7 @@
{% if obj.local_context_data %} - {% include 'extras/inc/configcontext_data.html' with data=obj.local_context_data %} + {% include 'extras/inc/configcontext_data.html' with data=obj.local_context_data format=format %} {% else %} None {% endif %} @@ -49,7 +49,7 @@ {% if context.description %}
{{ context.description }} {% endif %} - {% include 'extras/inc/configcontext_data.html' with data=context.data %} + {% include 'extras/inc/configcontext_data.html' with data=context.data format=format %}
{% empty %}
@@ -60,7 +60,3 @@
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 16efa6421..dcaaafdca 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block title %}{{ objectchange }}{% endblock %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index addb5fbda..8ddf74eca 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block title %}{{ report.name }}{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 609e8acc9..7de085974 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block content %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 6d7aca126..01dc4bfa5 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} {% load log_levels %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index a13298b2c..a1b97cfc2 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block content %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 64e5bbebd..0c20bcbdc 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block header %} @@ -82,20 +82,13 @@   + + Description + + {{ tag.description }} + -
-
- Comments -
-
- {% if tag.comments %} - {{ tag.comments|render_markdown }} - {% else %} - None - {% endif %} -
-
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html index 800db1d26..87b9a2e53 100644 --- a/netbox/templates/extras/tag_edit.html +++ b/netbox/templates/extras/tag_edit.html @@ -8,12 +8,7 @@ {% render_field form.name %} {% render_field form.slug %} {% render_field form.color %} -
- -
-
Comments
-
- {% render_field form.comments %} + {% render_field form.description %}
{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 6977bba4c..50a411048 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,6 +1,19 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} +{% block header %} + {{ block.super }} + {% if new_release %} + {# new_release is set only if the current user is a superuser or staff member #} + + {% endif %} +{% endblock %} + + {% block content %} {% include 'search_form.html' %}
diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html index dba525af5..da9958bdb 100644 --- a/netbox/templates/import_success.html +++ b/netbox/templates/import_success.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% block content %}

{% block title %}Import Completed{% endblock %}

diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 84892f726..8c1872273 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -7,7 +7,7 @@ {% for field, value in custom_fields.items %} - +
{{ field }}{{ field }} {% if field.type == 'boolean' and value == True %} @@ -15,7 +15,7 @@ {% elif field.type == 'url' and value %} {{ value|truncatechars:70 }} - {% elif field.type == 'integer' or value %} + {% elif value is not None %} {{ value }} {% elif field.required %} Not defined diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index d2eb93ebd..765df31cc 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -462,6 +462,7 @@ {% if perms.secrets.add_secret %}
+
{% endif %} @@ -503,6 +504,9 @@ + {% if registry.plugin_menu_items %} + {% include 'inc/plugin_menu_items.html' %} + {% endif %} {% endif %}
{% include 'inc/custom_fields_panel.html' with obj=secret %} + {% plugin_left_page secret %}
{% if secret|decryptable_by:request.user %} @@ -100,6 +103,12 @@
{% endif %} {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %} + {% plugin_right_page secret %} + + +
+
+ {% plugin_full_width_page secret %}
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 875e53c5c..cb3935521 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load static %} {% load form_helpers %} {% load secret_helpers %} @@ -21,12 +21,7 @@
Secret Attributes
-
- -
-

{{ secret.device }}

-
-
+ {% render_field form.device %} {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 4ef26c451..a9cf67398 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -1,7 +1,8 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons tenant %} {% if perms.tenancy.add_tenant %} {% clone_button tenant %} {% endif %} @@ -93,6 +95,7 @@ {% endif %}
+ {% plugin_left_page tenant %}
@@ -146,6 +149,12 @@
+ {% plugin_right_page tenant %} + + +
+
+ {% plugin_full_width_page tenant %}
{% endblock %} diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index b775af73e..690a966b0 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -1,4 +1,4 @@ -{% extends 'users/_user.html' %} +{% extends 'users/base.html' %} {% load helpers %} {% block title %}API Tokens{% endblock %} @@ -19,7 +19,7 @@ {% endif %} - {{ token.key }} + {{ token.key }} {% if token.is_expired %} Expired {% endif %} @@ -27,24 +27,24 @@
- {{ token.created|date }}
- Created + Created
+ {{ token.created|date }}
+ Expires
{% if token.expires %} - {{ token.expires|date }}
+ {{ token.expires|date }} {% else %} - Never
+ Never {% endif %} - Expires
+ Create/edit/delete operations
{% if token.write_enabled %} Enabled {% else %} Disabled - {% endif %}
- Create/edit/delete operations + {% endif %}
{% if token.description %} diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/base.html similarity index 75% rename from netbox/templates/users/_user.html rename to netbox/templates/users/base.html index 55df34228..972b3d7b5 100644 --- a/netbox/templates/users/_user.html +++ b/netbox/templates/users/base.html @@ -1,17 +1,20 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% block content %}
-
+

{% block title %}{% endblock %}

-
+
-
+
{% block usercontent %}{% endblock %}
diff --git a/netbox/templates/users/change_password.html b/netbox/templates/users/change_password.html index 700bf682d..20c6d048b 100644 --- a/netbox/templates/users/change_password.html +++ b/netbox/templates/users/change_password.html @@ -1,10 +1,10 @@ -{% extends 'users/_user.html' %} +{% extends 'users/base.html' %} {% load form_helpers %} {% block title %}Change Password{% endblock %} {% block usercontent %} -
+ {% csrf_token %} {% if form.non_field_errors %}
diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html new file mode 100644 index 000000000..8b3c4bcc4 --- /dev/null +++ b/netbox/templates/users/preferences.html @@ -0,0 +1,35 @@ +{% extends 'users/base.html' %} +{% load helpers %} + +{% block title %}User Preferences{% endblock %} + +{% block usercontent %} + {% if preferences %} + + {% csrf_token %} + + + + + + + + + + {% for key, value in preferences.items %} + + + + + + {% endfor %} + +
PreferenceValue
{{ key }}{{ value }}
+ + + {% else %} +

No preferences found

+ {% endif %} +{% endblock %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 7e7697991..35a94ac6f 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -1,4 +1,4 @@ -{% extends 'users/_user.html' %} +{% extends 'users/base.html' %} {% load helpers %} {% block title %}User Profile{% endblock %} diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index e98ec4030..2861d187e 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -1,4 +1,4 @@ -{% extends 'users/_user.html' %} +{% extends 'users/base.html' %} {% block title %}User Key{% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index 40c3715b0..0715f9038 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -1,4 +1,4 @@ -{% extends 'users/_user.html' %} +{% extends 'users/base.html' %} {% load static %} {% load form_helpers %} diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html index af15f7ab9..3a495f26f 100644 --- a/netbox/templates/utilities/confirmation_form.html +++ b/netbox/templates/utilities/confirmation_form.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/utilities/obj_bulk_add_component.html b/netbox/templates/utilities/obj_bulk_add_component.html index fb9fb0418..c0d1a6335 100644 --- a/netbox/templates/utilities/obj_bulk_add_component.html +++ b/netbox/templates/utilities/obj_bulk_add_component.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/utilities/obj_bulk_delete.html index eec5a3b42..d622a5ab0 100644 --- a/netbox/templates/utilities/obj_bulk_delete.html +++ b/netbox/templates/utilities/obj_bulk_delete.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block title %}Delete {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} diff --git a/netbox/templates/utilities/obj_bulk_edit.html b/netbox/templates/utilities/obj_bulk_edit.html index 2b12f2a07..b679f94c1 100644 --- a/netbox/templates/utilities/obj_bulk_edit.html +++ b/netbox/templates/utilities/obj_bulk_edit.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/utilities/obj_bulk_import.html index 97b093a02..4359d49a6 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -1,60 +1,97 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} {% block content %} -

{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

{% block tabs %}{% endblock %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} +
+
+

{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
-
- {% endif %} -
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel + {% endif %} + +
+
+ + {% csrf_token %} + {% render_form form %} +
+
+ + {% if return_url %} + Cancel + {% endif %} +
+
+ +
+

+ {% if fields %} +
+
+ CSV Field Options +
+ + + + + + + + {% for name, field in fields.items %} + + + + + + + {% endfor %} +
FieldRequiredAccessorDescription
+ {{ name }} + + {% if field.required %} + + {% else %} + + {% endif %} + + {% if field.to_field_name %} + {{ field.to_field_name }} + {% else %} + + {% endif %} + + {% if field.help_text %} + {{ field.help_text }}
+ {% elif field.label %} + {{ field.label }}
+ {% endif %} + {% if field|widget_type == 'dateinput' %} + Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} + Specify "true" or "false" + {% endif %} +
+
+

+ Required fields must be specified for all + objects. +

+

+ Related objects may be referenced by any unique attribute. + For example, vrf.rd would identify a VRF by its route distinguisher. +

{% endif %}
- -
-
- {% if fields %} -

CSV Format

- - - - - - - {% for name, field in fields.items %} - - - - - - {% endfor %} -
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %} - {{ field.help_text|default:field.label }} - {% if field.choices %} -
Choices: {{ field|example_choices }} - {% elif field|widget_type == 'dateinput' %} -
Format: YYYY-MM-DD - {% elif field|widget_type == 'checkboxinput' %} -
Specify "true" or "false" - {% endif %} -
- {% endif %} -
-
+
+
{% endblock %} diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/utilities/obj_bulk_remove.html index a850cf914..57af19e3d 100644 --- a/netbox/templates/utilities/obj_bulk_remove.html +++ b/netbox/templates/utilities/obj_bulk_remove.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 0d7760f30..5230b2594 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load form_helpers %} {% load helpers %} @@ -51,7 +51,7 @@
- {% if settings.DOCS_ROOT %} + {% if obj and settings.DOCS_ROOT %} {% include 'inc/modal.html' with name='docs' content=obj|get_docs %} {% endif %} {% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index d0ba99295..5b79a5ced 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 8f9a0563c..85ff050ed 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -1,10 +1,13 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load buttons %} {% load helpers %} {% block content %}
{% block buttons %}{% endblock %} + {% if request.user.is_authenticated and table_config_form %} + + {% endif %} {% if permissions.add and 'add' in action_buttons %} {% add_button content_type.model_class|url_name:"add" %} {% endif %} @@ -68,6 +71,9 @@ {% endwith %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+ {% if table_config_form %} + {% include 'inc/table_config_form.html' %} + {% endif %}
{% if filter_form %}
diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html index 8edc83f9c..638220f5a 100644 --- a/netbox/templates/utilities/templatetags/tag.html +++ b/netbox/templates/utilities/templatetags/tag.html @@ -1,5 +1,3 @@ {% load helpers %} -{% if url_name %}{% endif %} -{{ tag }} -{% if url_name %}{% endif %} +{% if url_name %}{% endif %}{{ tag }}{% if url_name %}{% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 4070977bc..0ff5e78f4 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,7 +1,8 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons cluster %} {% if perms.virtualization.add_cluster %} {% clone_button cluster %} {% endif %} @@ -121,6 +123,7 @@ {% endif %}
+ {% plugin_left_page cluster %}
@@ -135,7 +138,7 @@ {% if perms.virtualization.change_cluster %}
+
+
+ {% plugin_full_width_page cluster %} +
+
{% endblock %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index ca37d0df1..397f53d01 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load static %} {% load form_helpers %} @@ -22,26 +22,7 @@
Device Selection
- -
- -
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack %} -
-
- {% render_field form.devices %} + {% render_form form %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 33dd8130a..ea8f4fedb 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -1,8 +1,9 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load buttons %} {% load custom_links %} {% load static %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons virtualmachine %} {% if perms.virtualization.add_virtualmachine %} {% clone_button virtualmachine %} {% endif %} @@ -158,6 +160,7 @@ {% endif %}
+ {% plugin_left_page virtualmachine %}
@@ -235,6 +238,12 @@
{% endif %}
+ {% plugin_right_page virtualmachine %} +
+ +
+
+ {% plugin_full_width_page virtualmachine %}
diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index 90754519b..34a8f3c3d 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -1,4 +1,4 @@ -{% extends '_base.html' %} +{% extends 'base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 7599029c5..9c7a099e4 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -12,11 +12,12 @@ from .nested_serializers import * # class TenantGroupSerializer(ValidatedModelSerializer): + parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug', 'tenant_count'] + fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 5762f9a0d..645cc2edc 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -14,9 +14,6 @@ class TenancyRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = TenancyRootView -# Field choices -router.register('_choices', views.TenancyFieldChoicesViewSet, basename='field-choice') - # Tenants router.register('tenant-groups', views.TenantGroupViewSet) router.register('tenants', views.TenantViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index ab82c3cf5..148058a33 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,20 +4,12 @@ from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers -# -# Field choices -# - -class TenancyFieldChoicesViewSet(FieldChoicesViewSet): - fields = () - - # # Tenant Groups # diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 8ba3054aa..af5ee0b2c 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet -from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter from .models import Tenant, TenantGroup @@ -14,36 +14,45 @@ __all__ = ( class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + label='Tenant group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=TenantGroup.objects.all(), + to_field_name='slug', + label='Tenant group group (slug)', + ) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), - label='Group (ID)', + field_name='group', + lookup_expr='in', + label='Tenant group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( - field_name='group__slug', + group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), + field_name='group', + lookup_expr='in', to_field_name='slug', - label='Group (slug)', + label='Tenant group (slug)', ) tag = TagFilter() class Meta: model = Tenant - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] def search(self, queryset, name, value): if not value.strip(): @@ -60,16 +69,17 @@ class TenancyFilterSet(django_filters.FilterSet): """ An inheritable FilterSet for models which support Tenant assignment. """ - tenant_group_id = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__group__id', + tenant_group_id = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), - to_field_name='id', + field_name='tenant__group', + lookup_expr='in', label='Tenant Group (ID)', ) - tenant_group = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__group__slug', + tenant_group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), + field_name='tenant__group', to_field_name='slug', + lookup_expr='in', label='Tenant Group (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( @@ -77,8 +87,8 @@ class TenancyFilterSet(django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', queryset=Tenant.objects.all(), + field_name='tenant__slug', to_field_name='slug', label='Tenant (slug)', ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5b828b661..bf100f43a 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,12 +1,12 @@ from django import forms -from taggit.forms import TagField from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, + TagField, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SlugField, TagFilterField, + APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -16,24 +16,34 @@ from .models import Tenant, TenantGroup # class TenantGroupForm(BootstrapMixin, forms.ModelForm): + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + ) slug = SlugField() class Meta: model = TenantGroup fields = [ - 'name', 'slug', + 'parent', 'name', 'slug', 'description', ] -class TenantGroupCSVForm(forms.ModelForm): +class TenantGroupCSVForm(CSVModelForm): + parent = CSVModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) slug = SlugField() class Meta: model = TenantGroup fields = TenantGroup.csv_headers - help_texts = { - 'name': 'Group name', - } # @@ -44,10 +54,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -61,25 +68,18 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): ) -class TenantCSVForm(CustomFieldModelForm): +class TenantCSVForm(CustomFieldModelCSVForm): slug = SlugField() - group = forms.ModelChoiceField( + group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent group', - error_messages={ - 'invalid_choice': 'Group not found.' - } + help_text='Assigned group' ) class Meta: model = Tenant fields = Tenant.csv_headers - help_texts = { - 'name': 'Tenant name', - 'comments': 'Free-form comments' - } class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -89,10 +89,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) class Meta: @@ -112,7 +109,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, ) @@ -129,7 +125,6 @@ class TenancyForm(forms.Form): queryset=TenantGroup.objects.all(), required=False, widget=APISelect( - api_url="/api/tenancy/tenant-groups/", filter_for={ 'tenant': 'group_id', }, @@ -140,10 +135,7 @@ class TenancyForm(forms.Form): ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) def __init__(self, *args, **kwargs): @@ -164,7 +156,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, filter_for={ @@ -177,7 +168,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field="slug", null_option=True, ) diff --git a/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py b/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py deleted file mode 100644 index 664ea5d1b..000000000 --- a/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py +++ /dev/null @@ -1,45 +0,0 @@ -import django.db.models.deletion -import taggit.managers -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [('tenancy', '0001_initial'), ('tenancy', '0002_tenant_group_optional'), ('tenancy', '0003_unicode_literals'), ('tenancy', '0004_tags'), ('tenancy', '0005_change_logging')] - - dependencies = [ - ('taggit', '0002_auto_20150616_2121'), - ] - - operations = [ - migrations.CreateModel( - name='TenantGroup', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True)), - ('slug', models.SlugField(unique=True)), - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Tenant', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('name', models.CharField(max_length=30, unique=True)), - ('slug', models.SlugField(unique=True)), - ('description', models.CharField(blank=True, help_text='Long-form name (optional)', max_length=100)), - ('comments', models.TextField(blank=True)), - ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup')), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ], - options={ - 'ordering': ['group', 'name'], - }, - ), - ] diff --git a/netbox/tenancy/migrations/0007_nested_tenantgroups.py b/netbox/tenancy/migrations/0007_nested_tenantgroups.py new file mode 100644 index 000000000..4278b3409 --- /dev/null +++ b/netbox/tenancy/migrations/0007_nested_tenantgroups.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0006_custom_tag_models'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.TenantGroup'), + ), + migrations.AddField( + model_name='tenantgroup', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='tenantgroup', + name='lft', + field=models.PositiveIntegerField(default=1, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='tenantgroup', + name='rght', + field=models.PositiveIntegerField(default=2, editable=False), + preserve_default=False, + ), + # tree_id will be set to a valid value during the following migration (which needs to be a separate migration) + migrations.AddField( + model_name='tenantgroup', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + ] diff --git a/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py b/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py new file mode 100644 index 000000000..e31a75d36 --- /dev/null +++ b/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def rebuild_mptt(apps, schema_editor): + TenantGroup = apps.get_model('tenancy', 'TenantGroup') + for i, tenantgroup in enumerate(TenantGroup.objects.all(), start=1): + TenantGroup.objects.filter(pk=tenantgroup.pk).update(tree_id=i) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_nested_tenantgroups'), + ] + + operations = [ + migrations.RunPython( + code=rebuild_mptt, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/tenancy/migrations/0009_standardize_description.py b/netbox/tenancy/migrations/0009_standardize_description.py new file mode 100644 index 000000000..0f65ced04 --- /dev/null +++ b/netbox/tenancy/migrations/0009_standardize_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0008_nested_tenantgroups_rebuild'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='tenant', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9fa7f23ea..077fb6ad1 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,10 +1,13 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager -from extras.models import CustomFieldModel, TaggedItem +from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object __all__ = ( @@ -13,7 +16,7 @@ __all__ = ( ) -class TenantGroup(ChangeLoggedModel): +class TenantGroup(MPTTModel, ChangeLoggedModel): """ An arbitrary collection of Tenants. """ @@ -24,12 +27,27 @@ class TenantGroup(ChangeLoggedModel): slug = models.SlugField( unique=True ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: ordering = ['name'] + class MPTTMeta: + order_insertion_by = ['name'] + def __str__(self): return self.name @@ -40,9 +58,21 @@ class TenantGroup(ChangeLoggedModel): return ( self.name, self.slug, + self.parent.name if self.parent else '', + self.description, + ) + + def to_objectchange(self, action): + # Remove MPTT-internal fields + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal @@ -63,9 +93,8 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): null=True ) description = models.CharField( - max_length=100, - blank=True, - help_text='Long-form name (optional)' + max_length=200, + blank=True ) comments = models.TextField( blank=True diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index af4fb34c0..147a20707 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,8 +1,18 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, TagColumn, ToggleColumn from .models import Tenant, TenantGroup +MPTT_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + TENANTGROUP_ACTIONS = """ @@ -27,16 +37,23 @@ COL_TENANT = """ class TenantGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - tenant_count = tables.Column(verbose_name='Tenants') - slug = tables.Column(verbose_name='Slug') + name = tables.TemplateColumn( + template_code=MPTT_LINK, + orderable=False + ) + tenant_count = tables.Column( + verbose_name='Tenants' + ) actions = tables.TemplateColumn( - template_code=TENANTGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' + template_code=TENANTGROUP_ACTIONS, + attrs={'td': {'class': 'text-right noprint'}}, + verbose_name='' ) class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'name', 'tenant_count', 'slug', 'actions') + fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') # @@ -46,7 +63,11 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + tags = TagColumn( + url_name='tenancy:tenant_list' + ) class Meta(BaseTable.Meta): model = Tenant - fields = ('pk', 'name', 'group', 'description') + fields = ('pk', 'name', 'slug', 'group', 'description', 'tags') + default_columns = ('pk', 'name', 'group', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 495cb250d..8da3d7594 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -14,13 +14,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('tenancy-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - class TenantGroupTest(APITestCase): @@ -28,23 +21,34 @@ class TenantGroupTest(APITestCase): super().setUp() - self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') - self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') - self.tenantgroup3 = TenantGroup.objects.create(name='Test Tenant Group 3', slug='test-tenant-group-3') + self.parent_tenant_groups = ( + TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), + TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), + ) + for tenantgroup in self.parent_tenant_groups: + tenantgroup.save() + + self.tenant_groups = ( + TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]), + TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]), + ) + for tenantgroup in self.tenant_groups: + tenantgroup.save() def test_get_tenantgroup(self): - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.tenantgroup1.name) + self.assertEqual(response.data['name'], self.tenant_groups[0].name) def test_list_tenantgroups(self): url = reverse('tenancy-api:tenantgroup-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 5) def test_list_tenantgroups_brief(self): @@ -59,33 +63,38 @@ class TenantGroupTest(APITestCase): def test_create_tenantgroup(self): data = { - 'name': 'Test Tenant Group 4', - 'slug': 'test-tenant-group-4', + 'name': 'Tenant Group 4', + 'slug': 'tenant-group-4', + 'parent': self.parent_tenant_groups[0].pk, } url = reverse('tenancy-api:tenantgroup-list') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(TenantGroup.objects.count(), 4) + self.assertEqual(TenantGroup.objects.count(), 6) tenantgroup4 = TenantGroup.objects.get(pk=response.data['id']) self.assertEqual(tenantgroup4.name, data['name']) self.assertEqual(tenantgroup4.slug, data['slug']) + self.assertEqual(tenantgroup4.parent_id, data['parent']) def test_create_tenantgroup_bulk(self): data = [ { - 'name': 'Test Tenant Group 4', - 'slug': 'test-tenant-group-4', + 'name': 'Tenant Group 4', + 'slug': 'tenant-group-4', + 'parent': self.parent_tenant_groups[0].pk, }, { - 'name': 'Test Tenant Group 5', - 'slug': 'test-tenant-group-5', + 'name': 'Tenant Group 5', + 'slug': 'tenant-group-5', + 'parent': self.parent_tenant_groups[0].pk, }, { - 'name': 'Test Tenant Group 6', - 'slug': 'test-tenant-group-6', + 'name': 'Tenant Group 6', + 'slug': 'tenant-group-6', + 'parent': self.parent_tenant_groups[0].pk, }, ] @@ -93,7 +102,7 @@ class TenantGroupTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(TenantGroup.objects.count(), 6) + self.assertEqual(TenantGroup.objects.count(), 8) self.assertEqual(response.data[0]['name'], data[0]['name']) self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) @@ -101,26 +110,28 @@ class TenantGroupTest(APITestCase): def test_update_tenantgroup(self): data = { - 'name': 'Test Tenant Group X', - 'slug': 'test-tenant-group-x', + 'name': 'Tenant Group X', + 'slug': 'tenant-group-x', + 'parent': self.parent_tenant_groups[1].pk, } - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(TenantGroup.objects.count(), 3) + self.assertEqual(TenantGroup.objects.count(), 5) tenantgroup1 = TenantGroup.objects.get(pk=response.data['id']) self.assertEqual(tenantgroup1.name, data['name']) self.assertEqual(tenantgroup1.slug, data['slug']) + self.assertEqual(tenantgroup1.parent_id, data['parent']) def test_delete_tenantgroup(self): - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk}) + url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(TenantGroup.objects.count(), 2) + self.assertEqual(TenantGroup.objects.count(), 4) class TenantTest(APITestCase): @@ -129,18 +140,26 @@ class TenantTest(APITestCase): super().setUp() - self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') - self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') - self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1', group=self.tenantgroup1) - self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2', group=self.tenantgroup1) - self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3', group=self.tenantgroup1) + self.tenant_groups = ( + TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + ) + for tenantgroup in self.tenant_groups: + tenantgroup.save() + + self.tenants = ( + Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]), + Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]), + Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]), + ) + Tenant.objects.bulk_create(self.tenants) def test_get_tenant(self): - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.tenant1.name) + self.assertEqual(response.data['name'], self.tenants[0].name) def test_list_tenants(self): @@ -164,7 +183,7 @@ class TenantTest(APITestCase): data = { 'name': 'Test Tenant 4', 'slug': 'test-tenant-4', - 'group': self.tenantgroup1.pk, + 'group': self.tenant_groups[0].pk, } url = reverse('tenancy-api:tenant-list') @@ -208,10 +227,10 @@ class TenantTest(APITestCase): data = { 'name': 'Test Tenant X', 'slug': 'test-tenant-x', - 'group': self.tenantgroup2.pk, + 'group': self.tenant_groups[1].pk, } - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -223,7 +242,7 @@ class TenantTest(APITestCase): def test_delete_tenant(self): - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk}) + url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/netbox/tenancy/tests/test_filters.py b/netbox/tenancy/tests/test_filters.py index 300363c83..c78b25083 100644 --- a/netbox/tenancy/tests/test_filters.py +++ b/netbox/tenancy/tests/test_filters.py @@ -11,16 +11,24 @@ class TenantGroupTestCase(TestCase): @classmethod def setUpTestData(cls): - groups = ( - TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), - TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), + parent_tenant_groups = ( + TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), + TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), + TenantGroup(name='Parent Tenant Group 3', slug='parent-tenant-group-3'), ) - TenantGroup.objects.bulk_create(groups) + for tenantgroup in parent_tenant_groups: + tenantgroup.save() + + tenant_groups = ( + TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0], description='A'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1], description='B'), + TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2], description='C'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() def test_id(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id': [str(id) for id in id_list]} + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): @@ -31,6 +39,17 @@ class TenantGroupTestCase(TestCase): params = {'slug': ['tenant-group-1', 'tenant-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = TenantGroup.objects.filter(name__startswith='Parent')[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class TenantTestCase(TestCase): queryset = Tenant.objects.all() @@ -39,20 +58,25 @@ class TenantTestCase(TestCase): @classmethod def setUpTestData(cls): - groups = ( + tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) - TenantGroup.objects.bulk_create(groups) + for tenantgroup in tenant_groups: + tenantgroup.save() tenants = ( - Tenant(name='Tenant 1', slug='tenant-1', group=groups[0]), - Tenant(name='Tenant 2', slug='tenant-2', group=groups[1]), - Tenant(name='Tenant 3', slug='tenant-3', group=groups[2]), + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), ) Tenant.objects.bulk_create(tenants) + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): params = {'name': ['Tenant 1', 'Tenant 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -61,11 +85,6 @@ class TenantTestCase(TestCase): params = {'slug': ['tenant-1', 'tenant-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_id__in(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id__in': ','.join([str(id) for id in id_list])} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_group(self): group = TenantGroup.objects.all()[:2] params = {'group_id': [group[0].pk, group[1].pk]} diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 27e2c1591..ca2c2633f 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -8,22 +8,25 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - TenantGroup.objects.bulk_create([ + tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), - ]) + ) + for tenanantgroup in tenant_groups: + tenanantgroup.save() cls.form_data = { 'name': 'Tenant Group X', 'slug': 'tenant-group-x', + 'description': 'A new tenant group', } cls.csv_data = ( - "name,slug", - "Tenant Group 4,tenant-group-4", - "Tenant Group 5,tenant-group-5", - "Tenant Group 6,tenant-group-6", + "name,slug,description", + "Tenant Group 4,tenant-group-4,Fourth tenant group", + "Tenant Group 5,tenant-group-5,Fifth tenant group", + "Tenant Group 6,tenant-group-6,Sixth tenant group", ) @@ -33,22 +36,23 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - tenantgroups = ( + tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), ) - TenantGroup.objects.bulk_create(tenantgroups) + for tenanantgroup in tenant_groups: + tenanantgroup.save() Tenant.objects.bulk_create([ - Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroups[0]), - Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroups[0]), - Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroups[0]), + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), ]) cls.form_data = { 'name': 'Tenant X', 'slug': 'tenant-x', - 'group': tenantgroups[1].pk, + 'group': tenant_groups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', 'tags': 'Alpha,Bravo,Charlie', @@ -62,5 +66,5 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { - 'group': tenantgroups[1].pk, + 'group': tenant_groups[1].pk, } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0319a20b0..afc363cd6 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -20,7 +20,13 @@ from .models import Tenant, TenantGroup class TenantGroupListView(PermissionRequiredMixin, ObjectListView): permission_required = 'tenancy.view_tenantgroup' - queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) table = tables.TenantGroupTable diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 4549945bf..42e651712 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -3,18 +3,25 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import User -from netbox.admin import admin_site -from .models import Token +from .models import Token, UserConfig # Unregister the built-in UserAdmin so that we can use our custom admin view below -admin_site.unregister(User) +admin.site.unregister(User) -@admin.register(User, site=admin_site) +class UserConfigInline(admin.TabularInline): + model = UserConfig + readonly_fields = ('data',) + can_delete = False + verbose_name = 'Preferences' + + +@admin.register(User) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] + inlines = (UserConfigInline,) class TokenAdminForm(forms.ModelForm): @@ -30,7 +37,7 @@ class TokenAdminForm(forms.ModelForm): model = Token -@admin.register(Token, site=admin_site) +@admin.register(Token) class TokenAdmin(admin.ModelAdmin): form = TokenAdminForm list_display = [ diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py b/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py deleted file mode 100644 index 1053dcd7a..000000000 --- a/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py +++ /dev/null @@ -1,35 +0,0 @@ -import django.core.validators -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [('users', '0001_api_tokens'), ('users', '0002_unicode_literals'), ('users', '0003_token_permissions')] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Token', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('expires', models.DateTimeField(blank=True, null=True)), - ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])), - ('write_enabled', models.BooleanField(default=True, help_text='Permit create/update/delete operations using this key')), - ('description', models.CharField(blank=True, max_length=100)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'default_permissions': [], - }, - ), - migrations.AlterModelOptions( - name='token', - options={}, - ), - ] diff --git a/netbox/users/migrations/0004_standardize_description.py b/netbox/users/migrations/0004_standardize_description.py new file mode 100644 index 000000000..b1f45666f --- /dev/null +++ b/netbox/users/migrations/0004_standardize_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_token_permissions'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/users/migrations/0005_userconfig.py b/netbox/users/migrations/0005_userconfig.py new file mode 100644 index 000000000..f8dc64fc3 --- /dev/null +++ b/netbox/users/migrations/0005_userconfig.py @@ -0,0 +1,28 @@ +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0004_standardize_description'), + ] + + operations = [ + migrations.CreateModel( + name='UserConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['user'], + 'verbose_name': 'User Preferences', + 'verbose_name_plural': 'User Preferences' + }, + ), + ] diff --git a/netbox/users/migrations/0006_create_userconfigs.py b/netbox/users/migrations/0006_create_userconfigs.py new file mode 100644 index 000000000..397bfdb24 --- /dev/null +++ b/netbox/users/migrations/0006_create_userconfigs.py @@ -0,0 +1,27 @@ +from django.contrib.auth import get_user_model +from django.db import migrations + + +def create_userconfigs(apps, schema_editor): + """ + Create an empty UserConfig instance for each existing User. + """ + User = get_user_model() + UserConfig = apps.get_model('users', 'UserConfig') + UserConfig.objects.bulk_create( + [UserConfig(user_id=user.pk) for user in User.objects.all()] + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_userconfig'), + ] + + operations = [ + migrations.RunPython( + code=create_userconfigs, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index cf0d826b5..ea5762232 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -2,16 +2,142 @@ import binascii import os from django.contrib.auth.models import User +from django.contrib.postgres.fields import JSONField from django.core.validators import MinLengthValidator from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from django.utils import timezone +from utilities.utils import flatten_dict + __all__ = ( 'Token', + 'UserConfig', ) +class UserConfig(models.Model): + """ + This model stores arbitrary user-specific preferences in a JSON data structure. + """ + user = models.OneToOneField( + to=User, + on_delete=models.CASCADE, + related_name='config' + ) + data = JSONField( + default=dict + ) + + class Meta: + ordering = ['user'] + verbose_name = verbose_name_plural = 'User Preferences' + + def get(self, path, default=None): + """ + Retrieve a configuration parameter specified by its dotted path. Example: + + userconfig.get('foo.bar.baz') + + :param path: Dotted path to the configuration key. For example, 'foo.bar' returns self.data['foo']['bar']. + :param default: Default value to return for a nonexistent key (default: None). + """ + d = self.data + keys = path.split('.') + + # Iterate down the hierarchy, returning the default value if any invalid key is encountered + for key in keys: + if type(d) is dict and key in d: + d = d.get(key) + else: + return default + + return d + + def all(self): + """ + Return a dictionary of all defined keys and their values. + """ + return flatten_dict(self.data) + + def set(self, path, value, commit=False): + """ + Define or overwrite a configuration parameter. Example: + + userconfig.set('foo.bar.baz', 123) + + Leaf nodes (those which are not dictionaries of other nodes) cannot be overwritten as dictionaries. Similarly, + branch nodes (dictionaries) cannot be overwritten as single values. (A TypeError exception will be raised.) In + both cases, the existing key must first be cleared. This safeguard is in place to help avoid inadvertently + overwriting the wrong key. + + :param path: Dotted path to the configuration key. For example, 'foo.bar' sets self.data['foo']['bar']. + :param value: The value to be written. This can be any type supported by JSON. + :param commit: If true, the UserConfig instance will be saved once the new value has been applied. + """ + d = self.data + keys = path.split('.') + + # Iterate through the hierarchy to find the key we're setting. Raise TypeError if we encounter any + # interim leaf nodes (keys which do not contain dictionaries). + for i, key in enumerate(keys[:-1]): + if key in d and type(d[key]) is dict: + d = d[key] + elif key in d: + err_path = '.'.join(path.split('.')[:i + 1]) + raise TypeError(f"Key '{err_path}' is a leaf node; cannot assign new keys") + else: + d = d.setdefault(key, {}) + + # Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node. + key = keys[-1] + if key in d and type(d[key]) is dict: + raise TypeError(f"Key '{path}' has child keys; cannot assign a value") + else: + d[key] = value + + if commit: + self.save() + + def clear(self, path, commit=False): + """ + Delete a configuration parameter specified by its dotted path. The key and any child keys will be deleted. + Example: + + userconfig.clear('foo.bar.baz') + + Invalid keys will be ignored silently. + + :param path: Dotted path to the configuration key. For example, 'foo.bar' deletes self.data['foo']['bar']. + :param commit: If true, the UserConfig instance will be saved once the new value has been applied. + """ + d = self.data + keys = path.split('.') + + for key in keys[:-1]: + if key not in d: + break + if type(d[key]) is dict: + d = d[key] + + key = keys[-1] + d.pop(key, None) # Avoid a KeyError on invalid keys + + if commit: + self.save() + + +@receiver(post_save, sender=User) +def create_userconfig(instance, created, **kwargs): + """ + Automatically create a new UserConfig when a new User is created. + """ + if created: + UserConfig(user=instance).save() + + class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -39,7 +165,7 @@ class Token(models.Model): help_text='Permit create/update/delete operations using this key' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) diff --git a/netbox/users/tests/__init__.py b/netbox/users/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py new file mode 100644 index 000000000..8047796c4 --- /dev/null +++ b/netbox/users/tests/test_models.py @@ -0,0 +1,108 @@ +from django.contrib.auth.models import User +from django.test import TestCase + +from users.models import UserConfig + + +class UserConfigTest(TestCase): + + def setUp(self): + + user = User.objects.create_user(username='testuser') + user.config.data = { + 'a': True, + 'b': { + 'foo': 101, + 'bar': 102, + }, + 'c': { + 'foo': { + 'x': 201, + }, + 'bar': { + 'y': 202, + }, + 'baz': { + 'z': 203, + } + } + } + user.config.save() + + self.userconfig = user.config + + def test_get(self): + userconfig = self.userconfig + + # Retrieve root and nested values + self.assertEqual(userconfig.get('a'), True) + self.assertEqual(userconfig.get('b.foo'), 101) + self.assertEqual(userconfig.get('c.baz.z'), 203) + + # Invalid values should return None + self.assertIsNone(userconfig.get('invalid')) + self.assertIsNone(userconfig.get('a.invalid')) + self.assertIsNone(userconfig.get('b.foo.invalid')) + self.assertIsNone(userconfig.get('b.foo.x.invalid')) + + # Invalid values with a provided default should return the default + self.assertEqual(userconfig.get('invalid', 'DEFAULT'), 'DEFAULT') + self.assertEqual(userconfig.get('a.invalid', 'DEFAULT'), 'DEFAULT') + self.assertEqual(userconfig.get('b.foo.invalid', 'DEFAULT'), 'DEFAULT') + self.assertEqual(userconfig.get('b.foo.x.invalid', 'DEFAULT'), 'DEFAULT') + + def test_all(self): + userconfig = self.userconfig + flattened_data = { + 'a': True, + 'b.foo': 101, + 'b.bar': 102, + 'c.foo.x': 201, + 'c.bar.y': 202, + 'c.baz.z': 203, + } + + # Retrieve a flattened dictionary containing all config data + self.assertEqual(userconfig.all(), flattened_data) + + def test_set(self): + userconfig = self.userconfig + + # Overwrite existing values + userconfig.set('a', 'abc') + userconfig.set('c.foo.x', 'abc') + self.assertEqual(userconfig.data['a'], 'abc') + self.assertEqual(userconfig.data['c']['foo']['x'], 'abc') + + # Create new values + userconfig.set('d', 'abc') + userconfig.set('b.baz', 'abc') + self.assertEqual(userconfig.data['d'], 'abc') + self.assertEqual(userconfig.data['b']['baz'], 'abc') + + # Set a value and commit to the database + userconfig.set('a', 'def', commit=True) + + userconfig.refresh_from_db() + self.assertEqual(userconfig.data['a'], 'def') + + # Attempt to change a branch node to a leaf node + with self.assertRaises(TypeError): + userconfig.set('b', 1) + + # Attempt to change a leaf node to a branch node + with self.assertRaises(TypeError): + userconfig.set('a.x', 1) + + def test_clear(self): + userconfig = self.userconfig + + # Clear existing values + userconfig.clear('a') + userconfig.clear('b.foo') + self.assertTrue('a' not in userconfig.data) + self.assertTrue('foo' not in userconfig.data['b']) + self.assertEqual(userconfig.data['b']['bar'], 102) + + # Clear a non-existing value; should fail silently + userconfig.clear('invalid') diff --git a/netbox/users/urls.py b/netbox/users/urls.py index dae540726..b8b16cdf8 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -6,6 +6,7 @@ app_name = 'user' urlpatterns = [ path('profile/', views.ProfileView.as_view(), name='profile'), + path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 6a2410274..c3e366542 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash @@ -24,6 +26,9 @@ from .models import Token # class LoginView(View): + """ + Perform user authentication via the web UI. + """ template_name = 'login.html' @method_decorator(sensitive_post_parameters('password')) @@ -38,36 +43,51 @@ class LoginView(View): }) def post(self, request): + logger = logging.getLogger('netbox.auth.login') form = LoginForm(request, data=request.POST) + if form.is_valid(): + logger.debug("Login form validation was successful") # Determine where to direct user after successful login - redirect_to = request.POST.get('next', '') - if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + redirect_to = request.POST.get('next') + if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") redirect_to = reverse('home') # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. if settings.MAINTENANCE_MODE: + logger.warning("Maintenance mode enabled: disabling update of most recent login time") user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login') # Authenticate user auth_login(request, form.get_user()) + logger.info(f"User {request.user} successfully authenticated") messages.info(request, "Logged in as {}.".format(request.user)) + logger.debug(f"Redirecting user to {redirect_to}") return HttpResponseRedirect(redirect_to) + else: + logger.debug("Login form validation failed") + return render(request, self.template_name, { 'form': form, }) class LogoutView(View): - + """ + Deauthenticate a web user. + """ def get(self, request): + logger = logging.getLogger('netbox.auth.logout') # Log out the user + username = request.user auth_logout(request) + logger.info(f"User {username} has logged out") messages.info(request, "You have logged out.") # Delete session key cookie (if set) upon logout @@ -91,6 +111,30 @@ class ProfileView(LoginRequiredMixin, View): }) +class UserConfigView(LoginRequiredMixin, View): + template_name = 'users/preferences.html' + + def get(self, request): + + return render(request, self.template_name, { + 'preferences': request.user.config.all(), + 'active_tab': 'preferences', + }) + + def post(self, request): + userconfig = request.user.config + data = userconfig.all() + + # Delete selected preferences + for key in request.POST.getlist('pk'): + if key in data: + userconfig.clear(key) + userconfig.save() + messages.success(request, "Your preferences have been updated.") + + return redirect('user:preferences') + + class ChangePasswordView(LoginRequiredMixin, View): template_name = 'users/change_password.html' diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 72a5735de..205055669 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,3 +1,4 @@ +import logging from collections import OrderedDict import pytz @@ -234,6 +235,7 @@ class ValidatedModelSerializer(ModelSerializer): for k, v in attrs.items(): setattr(instance, k, v) instance.clean() + instance.validate_unique() return data @@ -303,25 +305,35 @@ class ModelViewSet(_ModelViewSet): return super().get_serializer(*args, **kwargs) def get_serializer_class(self): + logger = logging.getLogger('netbox.api.views.ModelViewSet') # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one # exists request = self.get_serializer_context()['request'] - if request.query_params.get('brief', False): + if request.query_params.get('brief'): + logger.debug("Request is for 'brief' format; initializing nested serializer") try: - return get_serializer_for_model(self.queryset.model, prefix='Nested') + serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') + logger.debug(f"Using serializer {serializer}") + return serializer except SerializerNotFound: pass # Fall back to the hard-coded serializer class + logger.debug(f"Using serializer {self.serializer_class}") return self.serializer_class def dispatch(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + try: return super().dispatch(request, *args, **kwargs) except ProtectedError as e: - models = ['{} ({})'.format(o, o._meta) for o in e.protected_objects.all()] + models = [ + '{} ({})'.format(o, o._meta) for o in e.protected_objects.all() + ] msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models)) + logger.warning(msg) return self.finalize_response( request, Response({'detail': msg}, status=409), @@ -341,48 +353,22 @@ class ModelViewSet(_ModelViewSet): """ return super().retrieve(*args, **kwargs) + # + # Logging + # -class FieldChoicesViewSet(ViewSet): - """ - Expose the built-in numeric values which represent static choices for a model's field. - """ - permission_classes = [IsAuthenticatedOrLoginNotRequired] - fields = [] + def perform_create(self, serializer): + model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Creating new {model._meta.verbose_name}") + return super().perform_create(serializer) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def perform_update(self, serializer): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") + return super().perform_update(serializer) - # Compile a dict of all fields in this view - self._fields = OrderedDict() - for serializer_class, field_list in self.fields: - for field_name in field_list: - - model_name = serializer_class.Meta.model._meta.verbose_name - key = ':'.join([model_name.lower().replace(' ', '-'), field_name]) - serializer = serializer_class() - choices = [] - - for k, v in serializer.get_fields()[field_name].choices.items(): - if type(v) in [list, tuple]: - for k2, v2 in v: - choices.append({ - 'value': k2, - 'label': v2, - }) - else: - choices.append({ - 'value': k, - 'label': v, - }) - self._fields[key] = choices - - def list(self, request): - return Response(self._fields) - - def retrieve(self, request, pk): - if pk not in self._fields: - raise Http404 - return Response(self._fields[pk]) - - def get_view_name(self): - return "Field Choices" + def perform_destroy(self, instance): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Deleting {instance} (PK: {instance.pk})") + return super().perform_destroy(instance) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 54541b0b5..6342bad2b 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -1,5 +1,8 @@ +import logging + from django.conf import settings -from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ +from django.contrib.auth.models import Group, Permission class ViewExemptModelBackend(ModelBackend): @@ -26,3 +29,45 @@ class ViewExemptModelBackend(ModelBackend): pass return super().has_perm(user_obj, perm, obj) + + +class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): + """ + Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. + """ + @property + def create_unknown_user(self): + return settings.REMOTE_AUTH_AUTO_CREATE_USER + + def configure_user(self, request, user): + logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + + # Assign default groups to the user + group_list = [] + for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: + try: + group_list.append(Group.objects.get(name=name)) + except Group.DoesNotExist: + logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if group_list: + user.groups.add(*group_list) + logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}") + + # Assign default permissions to the user + permissions_list = [] + for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: + try: + app_label, codename = permission_name.split('.') + permissions_list.append( + Permission.objects.get(content_type__app_label=app_label, codename=codename) + ) + except (ValueError, Permission.DoesNotExist): + logging.error( + "Invalid permission name: '{permission_name}'. Permissions must be in the form " + "._. (Example: dcim.add_site)" + ) + if permissions_list: + user.user_permissions.add(*permissions_list) + logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") + + return user diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py new file mode 100644 index 000000000..79633f47f --- /dev/null +++ b/netbox/utilities/background_tasks.py @@ -0,0 +1,52 @@ +import logging + +import requests +from cacheops.simple import cache, CacheMiss +from django.conf import settings +from django_rq import job +from packaging import version + +# Get an instance of a logger +logger = logging.getLogger('netbox.releases') + + +@job('check_releases') +def get_releases(pre_releases=False): + url = settings.RELEASE_CHECK_URL + headers = { + 'Accept': 'application/vnd.github.v3+json', + } + releases = [] + + # Check whether this URL has failed recently and shouldn't be retried yet + try: + if url == cache.get('latest_release_no_retry'): + logger.info("Skipping release check; URL failed recently: {}".format(url)) + return [] + except CacheMiss: + pass + + try: + logger.debug("Fetching new releases from {}".format(url)) + response = requests.get(url, headers=headers, proxies=settings.HTTP_PROXIES) + response.raise_for_status() + total_releases = len(response.json()) + + for release in response.json(): + if 'tag_name' not in release: + continue + if not pre_releases and (release.get('devrelease') or release.get('prerelease')): + continue + releases.append((version.parse(release['tag_name']), release.get('html_url'))) + logger.debug("Found {} releases; {} usable".format(total_releases, len(releases))) + + except requests.exceptions.RequestException: + # The request failed. Set a flag in the cache to disable future checks to this URL for 15 minutes. + logger.exception("Error while fetching {}. Disabling checks for 15 minutes.".format(url)) + cache.set('latest_release_no_retry', url, 900) + return [] + + # Cache the most recent release + cache.set('latest_release', max(releases), settings.RELEASE_CHECK_TIMEOUT) + + return releases diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 19082dbb6..ce0929a8b 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -78,3 +78,94 @@ def unpack_grouped_choices(choices): else: unpacked_choices.append((key, value)) return unpacked_choices + + +# +# Generic color choices +# + +class ColorChoices(ChoiceSet): + COLOR_DARK_RED = 'aa1409' + COLOR_RED = 'f44336' + COLOR_PINK = 'e91e63' + COLOR_ROSE = 'ffe4e1' + COLOR_FUCHSIA = 'ff66ff' + COLOR_PURPLE = '9c27b0' + COLOR_DARK_PURPLE = '673ab7' + COLOR_INDIGO = '3f51b5' + COLOR_BLUE = '2196f3' + COLOR_LIGHT_BLUE = '03a9f4' + COLOR_CYAN = '00bcd4' + COLOR_TEAL = '009688' + COLOR_AQUA = '00ffff' + COLOR_DARK_GREEN = '2f6a31' + COLOR_GREEN = '4caf50' + COLOR_LIGHT_GREEN = '8bc34a' + COLOR_LIME = 'cddc39' + COLOR_YELLOW = 'ffeb3b' + COLOR_AMBER = 'ffc107' + COLOR_ORANGE = 'ff9800' + COLOR_DARK_ORANGE = 'ff5722' + COLOR_BROWN = '795548' + COLOR_LIGHT_GREY = 'c0c0c0' + COLOR_GREY = '9e9e9e' + COLOR_DARK_GREY = '607d8b' + COLOR_BLACK = '111111' + COLOR_WHITE = 'ffffff' + + CHOICES = ( + (COLOR_DARK_RED, 'Dark red'), + (COLOR_RED, 'Red'), + (COLOR_PINK, 'Pink'), + (COLOR_ROSE, 'Rose'), + (COLOR_FUCHSIA, 'Fuchsia'), + (COLOR_PURPLE, 'Purple'), + (COLOR_DARK_PURPLE, 'Dark purple'), + (COLOR_INDIGO, 'Indigo'), + (COLOR_BLUE, 'Blue'), + (COLOR_LIGHT_BLUE, 'Light blue'), + (COLOR_CYAN, 'Cyan'), + (COLOR_TEAL, 'Teal'), + (COLOR_AQUA, 'Aqua'), + (COLOR_DARK_GREEN, 'Dark green'), + (COLOR_GREEN, 'Green'), + (COLOR_LIGHT_GREEN, 'Light green'), + (COLOR_LIME, 'Lime'), + (COLOR_YELLOW, 'Yellow'), + (COLOR_AMBER, 'Amber'), + (COLOR_ORANGE, 'Orange'), + (COLOR_DARK_ORANGE, 'Dark orange'), + (COLOR_BROWN, 'Brown'), + (COLOR_LIGHT_GREY, 'Light grey'), + (COLOR_GREY, 'Grey'), + (COLOR_DARK_GREY, 'Dark grey'), + (COLOR_BLACK, 'Black'), + (COLOR_WHITE, 'White'), + ) + + +# +# Button color choices +# + +class ButtonColorChoices(ChoiceSet): + """ + Map standard button color choices to Bootstrap color classes + """ + DEFAULT = 'default' + BLUE = 'primary' + GREY = 'secondary' + GREEN = 'success' + RED = 'danger' + YELLOW = 'warning' + BLACK = 'dark' + + CHOICES = ( + (DEFAULT, 'Default'), + (BLUE, 'Blue'), + (GREY, 'Grey'), + (GREEN, 'Green'), + (RED, 'Red'), + (YELLOW, 'Yellow'), + (BLACK, 'Black') + ) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index bdcdeef11..9a3a7d028 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -1,34 +1,3 @@ -COLOR_CHOICES = ( - ('aa1409', 'Dark red'), - ('f44336', 'Red'), - ('e91e63', 'Pink'), - ('ffe4e1', 'Rose'), - ('ff66ff', 'Fuschia'), - ('9c27b0', 'Purple'), - ('673ab7', 'Dark purple'), - ('3f51b5', 'Indigo'), - ('2196f3', 'Blue'), - ('03a9f4', 'Light blue'), - ('00bcd4', 'Cyan'), - ('009688', 'Teal'), - ('00ffff', 'Aqua'), - ('2f6a31', 'Dark green'), - ('4caf50', 'Green'), - ('8bc34a', 'Light green'), - ('cddc39', 'Lime'), - ('ffeb3b', 'Yellow'), - ('ffc107', 'Amber'), - ('ff9800', 'Orange'), - ('ff5722', 'Dark orange'), - ('795548', 'Brown'), - ('c0c0c0', 'Light grey'), - ('9e9e9e', 'Grey'), - ('607d8b', 'Dark grey'), - ('111111', 'Black'), - ('ffffff', 'White'), -) - - # # Filter lookup expressions # diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index 06c5c8784..87a5e39d8 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,10 +1,13 @@ from django.conf import settings as django_settings +from extras.registry import registry -def settings(request): + +def settings_and_registry(request): """ - Expose Django settings in the template context. Example: {{ settings.DEBUG }} + Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }} """ return { 'settings': django_settings, + 'registry': registry, } diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 553d98982..2cbe1cfc5 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -92,7 +92,7 @@ class CustomChoiceFieldInspector(FieldInspector): value_schema = openapi.Schema(type=schema_type, enum=choice_value) value_schema['x-nullable'] = True - if isinstance(choice_value[0], int): + if all(type(x) == int for x in [c for c in choice_value if c is not None]): # Change value_schema for IPAddressFamilyChoices, RackWidthChoices value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) @@ -131,16 +131,6 @@ class JSONFieldInspector(FieldInspector): return result -class IdInFilterInspector(FilterInspector): - def process_result(self, result, method_name, obj, **kwargs): - if isinstance(result, list): - params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in'] - for p in params: - p.type = 'string' - - return result - - class NullablePaginatorInspector(PaginatorInspector): def process_result(self, result, method_name, obj, **kwargs): if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema): diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index ff34a6011..f628ca917 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -80,13 +80,6 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): return super().filter(qs, value) -class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): - """ - Filters for a set of numeric values. Example: id__in=100,200,300 - """ - pass - - class NullableCharFieldFilter(django_filters.CharFilter): """ Allow matching on null field values by passing a special string used to signify NULL. @@ -217,9 +210,7 @@ class BaseFilterSet(django_filters.FilterSet): For specific filter types, new filters are created based on defined lookup expressions in the form `__` """ - # TODO: once 3.6 is the minimum required version of python, change this to a bare super() call - # We have to do it this way in py3.5 becuase of django_filters.FilterSet's use of a metaclass - filters = super(django_filters.FilterSet, cls).get_filters() + filters = super().get_filters() new_filters = {} for existing_filter_name, existing_filter in filters.items(): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8825102d1..979b6ac32 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -8,11 +8,13 @@ import yaml from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput +from django.core.exceptions import MultipleObjectsReturned from django.db.models import Count from django.forms import BoundField +from django.forms.models import fields_for_model +from django.urls import reverse -from .choices import unpack_grouped_choices -from .constants import * +from .choices import ColorChoices, unpack_grouped_choices from .validators import EnhancedURLValidator NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' @@ -122,6 +124,19 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) +def form_from_model(model, fields): + """ + Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used + for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields + are marked as not required. + """ + form_fields = fields_for_model(model, fields=fields) + for field in form_fields.values(): + field.required = False + + return type('FormFromModel', (forms.Form,), form_fields) + + # # Widgets # @@ -147,7 +162,7 @@ class ColorSelect(forms.Select): option_template_name = 'widgets/colorselect_option.html' def __init__(self, *args, **kwargs): - kwargs['choices'] = add_blank_choice(COLOR_CHOICES) + kwargs['choices'] = add_blank_choice(ColorChoices) super().__init__(*args, **kwargs) self.attrs['class'] = 'netbox-select2-color-picker' @@ -252,7 +267,7 @@ class APISelect(SelectWithDisabled): """ A select widget populated via an API call - :param api_url: API URL + :param api_url: API endpoint URL. Required if not set automatically by the parent field. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. @@ -269,7 +284,7 @@ class APISelect(SelectWithDisabled): """ def __init__( self, - api_url, + api_url=None, display_field=None, value_field=None, disabled_indicator=None, @@ -285,7 +300,8 @@ class APISelect(SelectWithDisabled): super().__init__(*args, **kwargs) self.attrs['class'] = 'netbox-select2-api' - self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH + if api_url: + self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH if full: self.attrs['data-full'] = full if display_field: @@ -384,15 +400,22 @@ class TimePicker(forms.TextInput): class CSVDataField(forms.CharField): """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping - column headers to values. Each dictionary represents an individual record. + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. """ widget = forms.Textarea - def __init__(self, fields, required_fields=[], *args, **kwargs): + def __init__(self, from_form, *args, **kwargs): - self.fields = fields - self.required_fields = required_fields + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] super().__init__(*args, **kwargs) @@ -400,7 +423,7 @@ class CSVDataField(forms.CharField): if not self.label: self.label = '' if not self.initial: - self.initial = ','.join(required_fields) + '\n' + self.initial = ','.join(self.required_fields) + '\n' if not self.help_text: self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ @@ -409,36 +432,55 @@ class CSVDataField(forms.CharField): def to_python(self, value): records = [] - reader = csv.reader(StringIO(value)) + reader = csv.reader(StringIO(value.strip())) - # Consume and validate the first line of CSV data as column headers - headers = next(reader) + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + headers = {} + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + def validate(self, value): + headers, records = value + + # Validate provided column headers + for field, to_field in headers.items(): + if field not in self.fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(self.fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(self.fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields for f in self.required_fields: if f not in headers: - raise forms.ValidationError('Required column header "{}" not found.'.format(f)) - for f in headers: - if f not in self.fields: - raise forms.ValidationError('Unexpected column header "{}" found.'.format(f)) + raise forms.ValidationError(f'Required column header "{f}" not found.') - # Parse CSV data - for i, row in enumerate(reader, start=1): - if row: - if len(row) != len(headers): - raise forms.ValidationError( - "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row)) - ) - row = [col.strip() for col in row] - record = dict(zip(headers, row)) - records.append(record) - - return records + return value class CSVChoiceField(forms.ChoiceField): """ Invert the provided set of choices to take the human-friendly label as input, and return the database value. """ - def __init__(self, choices, *args, **kwargs): super().__init__(choices=choices, *args, **kwargs) self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] @@ -453,6 +495,23 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Provides additional validation for model choices entered as CSV data. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned as e: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion @@ -514,27 +573,6 @@ class CommentField(forms.CharField): super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) -class FlexibleModelChoiceField(forms.ModelChoiceField): - """ - Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`. - """ - def to_python(self, value): - if value in self.empty_values: - return None - try: - if not self.to_field_name: - key = 'pk' - elif re.match(r'^\{\d+\}$', value): - key = 'pk' - value = value.strip('{}') - else: - key = self.to_field_name - value = self.queryset.get(**{key: value}) - except (ValueError, TypeError, self.queryset.model.DoesNotExist): - raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice') - return value - - class SlugField(forms.SlugField): """ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. @@ -566,19 +604,34 @@ class TagFilterField(forms.MultipleChoiceField): class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter + widget = APISelect + + def _get_initial_value(self, initial_data, field_name): + return initial_data.get(field_name) def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) + # Override initial() to allow passing multiple values + bound_field.initial = self._get_initial_value(form.initial, field_name) + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. - data = self.prepare_value(bound_field.data or bound_field.initial) + data = bound_field.value() if data: filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset) self.queryset = filter.filter(self.queryset, data) else: self.queryset = self.queryset.none() + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + return bound_field @@ -595,6 +648,13 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip A multiple-choice version of DynamicModelChoiceField. """ filter = django_filters.ModelMultipleChoiceFilter + widget = APISelectMultiple + + def _get_initial_value(self, initial_data, field_name): + # If a QueryDict has been passed as initial form data, get *all* listed values + if hasattr(initial_data, 'getlist'): + return initial_data.getlist(field_name) + return initial_data.get(field_name) class LaxURLField(forms.URLField): @@ -636,7 +696,10 @@ class BootstrapMixin(forms.BaseForm): super().__init__(*args, **kwargs) exempt_widgets = [ - forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect + forms.CheckboxInput, + forms.ClearableFileInput, + forms.FileInput, + forms.RadioSelect ] for field_name, field in self.fields.items(): @@ -677,13 +740,27 @@ class BulkEditForm(forms.Form): self.nullable_fields = self.Meta.nullable_fields +class CSVModelForm(forms.ModelForm): + """ + ModelForm used for the import of objects in CSV format. + """ + def __init__(self, *args, headers=None, **kwargs): + super().__init__(*args, **kwargs) + + # Modify the model form to accommodate any customized to_field_name properties + if headers: + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + class ImportForm(BootstrapMixin, forms.Form): """ Generic form for creating an object from JSON/YAML data """ data = forms.CharField( widget=forms.Textarea, - help_text="Enter object data in JSON or YAML format." + help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." ) format = forms.ChoiceField( choices=( @@ -702,14 +779,44 @@ class ImportForm(BootstrapMixin, forms.Form): if format == 'json': try: self.cleaned_data['data'] = json.loads(data) + # Check for multiple JSON objects + if type(self.cleaned_data['data']) is not dict: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) except json.decoder.JSONDecodeError as err: raise forms.ValidationError({ 'data': "Invalid JSON data: {}".format(err) }) else: + # Check for multiple YAML documents + if '\n---' in data: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) try: self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) - except yaml.scanner.ScannerError as err: + except yaml.error.YAMLError as err: raise forms.ValidationError({ 'data': "Invalid YAML data: {}".format(err) }) + + +class TableConfigForm(BootstrapMixin, forms.Form): + """ + Form for configuring user's table preferences. + """ + columns = forms.MultipleChoiceField( + choices=[], + widget=forms.SelectMultiple( + attrs={'size': 10} + ), + help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." + ) + + def __init__(self, table, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialize columns field based on table attributes + self.fields['columns'].choices = table.configurable_columns + self.fields['columns'].initial = table.visible_columns diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index c941f90e8..12a71c469 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -1,6 +1,7 @@ from urllib import parse from django.conf import settings +from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect from django.urls import reverse @@ -31,6 +32,25 @@ class LoginRequiredMiddleware(object): return self.get_response(request) +class RemoteUserMiddleware(RemoteUserMiddleware_): + """ + Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name. + """ + force_logout_if_no_header = False + + @property + def header(self): + return settings.REMOTE_AUTH_HEADER + + def process_request(self, request): + + # Bypass middleware if remote authentication is not enabled + if not settings.REMOTE_AUTH_ENABLED: + return + + return super().process_request(request) + + class APIVersionMiddleware(object): """ If the request is for an API endpoint, include the API version as a response header. diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index 346a99488..c5287b1e1 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -75,7 +75,7 @@ def naturalize_interface(value, max_length): if part is not None: output += part.rjust(6, '0') else: - output += '000000' + output += '......' # Finally, naturalize any remaining text and append it if match.group('remainder') is not None and len(output) < max_length: diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index cf91df3ca..cdad1f230 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -37,3 +37,25 @@ class EnhancedPage(Page): page_list.insert(page_list.index(i), False) return page_list + + +def get_paginate_count(request): + """ + Determine the length of a page, using the following in order: + + 1. per_page URL query parameter + 2. Saved user preference + 3. PAGINATE_COUNT global setting. + """ + if 'per_page' in request.GET: + try: + per_page = int(request.GET.get('per_page')) + if request.user.is_authenticated: + request.user.config.set('pagination.per_page', per_page, commit=True) + return per_page + except ValueError: + pass + + if request.user.is_authenticated: + return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) + return settings.PAGINATE_COUNT diff --git a/netbox/utilities/query_functions.py b/netbox/utilities/query_functions.py new file mode 100644 index 000000000..ee4310ea7 --- /dev/null +++ b/netbox/utilities/query_functions.py @@ -0,0 +1,9 @@ +from django.db.models import F, Func + + +class CollateAsChar(Func): + """ + Disregard localization by collating a field as a plain character string. Helpful for ensuring predictable ordering. + """ + function = 'C' + template = '(%(expressions)s) COLLATE "%(function)s"' diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 9e91aebd2..97108b5b2 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,22 +1,87 @@ import django_tables2 as tables +from django.core.exceptions import FieldDoesNotExist +from django.db.models.fields.related import RelatedField from django.utils.safestring import mark_safe +from django_tables2.data import TableQuerysetData class BaseTable(tables.Table): """ Default table for object lists + + :param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically + prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to + accommodate PrefixQuerySet.annotate_depth()). """ - def __init__(self, *args, **kwargs): + add_prefetch = True + + class Meta: + attrs = { + 'class': 'table table-hover table-headings', + } + + def __init__(self, *args, columns=None, **kwargs): super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: self.empty_text = 'No {} found'.format(self._meta.model._meta.verbose_name_plural) - class Meta: - attrs = { - 'class': 'table table-hover table-headings', - } + # Hide non-default columns + default_columns = getattr(self.Meta, 'default_columns', list()) + if default_columns: + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) + + # Apply custom column ordering + if columns is not None: + pk = self.base_columns.pop('pk', None) + actions = self.base_columns.pop('actions', None) + + for name, column in self.base_columns.items(): + if name in columns: + self.columns.show(name) + else: + self.columns.hide(name) + self.sequence = columns + + # Always include PK and actions column, if defined on the table + if pk: + self.base_columns['pk'] = pk + self.sequence.insert(0, 'pk') + if actions: + self.base_columns['actions'] = actions + self.sequence.append('actions') + + # Dynamically update the table's QuerySet to ensure related fields are pre-fetched + if self.add_prefetch and isinstance(self.data, TableQuerysetData): + model = getattr(self.Meta, 'model') + prefetch_fields = [] + for column in self.columns: + if column.visible: + field_path = column.accessor.split('.') + try: + model_field = model._meta.get_field(field_path[0]) + if isinstance(model_field, RelatedField): + prefetch_fields.append('__'.join(field_path)) + except FieldDoesNotExist: + pass + self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + + @property + def configurable_columns(self): + selected_columns = [ + (name, self.columns[name].verbose_name) for name in self.sequence if name not in ['pk', 'actions'] + ] + available_columns = [ + (name, column.verbose_name) for name, column in self.columns.items() if name not in self.sequence and name not in ['pk', 'actions'] + ] + return selected_columns + available_columns + + @property + def visible_columns(self): + return [name for name in self.sequence if self.columns[name].visible] class ToggleColumn(tables.CheckBoxColumn): @@ -62,3 +127,22 @@ class ColorColumn(tables.Column): return mark_safe( ' '.format(value) ) + + +class TagColumn(tables.TemplateColumn): + """ + Display a list of tags assigned to the object. + """ + template_code = """ + {% for tag in value.all %} + {% include 'utilities/templatetags/tag.html' %} + {% empty %} + + {% endfor %} + """ + + def __init__(self, url_name=None): + super().__init__( + template_code=self.template_code, + extra_context={'url_name': url_name} + ) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 618641a07..8a82fc48b 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -40,7 +40,7 @@ def render_markdown(value): value = strip_tags(value) # Render Markdown - html = markdown(value, extensions=['fenced_code']) + html = markdown(value, extensions=['fenced_code', 'tables']) return mark_safe(html) @@ -116,28 +116,6 @@ def humanize_speed(speed): return '{} Kbps'.format(speed) -@register.filter() -def example_choices(field, arg=3): - """ - Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms). - """ - examples = [] - if hasattr(field, 'queryset'): - choices = [ - (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1] - ] - else: - choices = field.choices - for value, label in unpack_grouped_choices(choices): - if len(examples) == arg: - examples.append('etc.') - break - if not value or not label: - continue - examples.append(label) - return ', '.join(examples) or 'None' - - @register.filter() def tzoffset(value): """ @@ -196,11 +174,19 @@ def get_docs(model): return "Unable to load documentation, error reading file: {}".format(path) # Render Markdown with the admonition extension - content = markdown(content, extensions=['admonition', 'fenced_code']) + content = markdown(content, extensions=['admonition', 'fenced_code', 'tables']) return mark_safe(content) +@register.filter() +def has_perms(user, permissions_list): + """ + Return True if the user has *all* permissions in the list. + """ + return user.has_perms(permissions_list) + + # # Tags # diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index de8b93232..d10bb025a 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -164,6 +164,13 @@ class ViewTestCases: response = self.client.get(instance.get_absolute_url()) self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_objects_anonymous(self): + # Make the request as an unauthenticated user + self.client.logout() + response = self.client.get(self.model.objects.first().get_absolute_url()) + self.assertHttpStatus(response, 200) + class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. @@ -287,6 +294,13 @@ class ViewTestCases: self.assertHttpStatus(response, 200) self.assertEqual(response.get('Content-Type'), 'text/csv') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_objects_anonymous(self): + # Make the request as an unauthenticated user + self.client.logout() + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + class BulkCreateObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 38ec6e196..fd8c70f05 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -36,33 +36,6 @@ def create_test_user(username='testuser', permissions=None): return user -def choices_to_dict(choices_list): - """ - Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example: - - [ - { - "value": "choice-1", - "label": "First Choice" - }, - { - "value": "choice-2", - "label": "Second Choice" - } - ] - - Becomes: - - { - "choice-1": "First Choice", - "choice-2": "Second Choice - } - """ - return { - choice['value']: choice['label'] for choice in choices_list - } - - @contextmanager def disable_warnings(logger_name): """ diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 2d7235505..d6af27b93 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,6 +1,8 @@ from django import forms from django.test import TestCase +from ipam.forms import IPAddressCSVForm +from ipam.models import VRF from utilities.forms import * @@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase): with self.assertRaises(ValueError): sorted(expand_alphanumeric_pattern('r[a,,b]a')) + + +class CSVDataFieldTest(TestCase): + + def setUp(self): + self.field = CSVDataField(from_form=IPAddressCSVForm) + + def test_clean(self): + input = """ + address,status,vrf + 192.0.2.1/32,Active,Test VRF + """ + output = ( + {'address': None, 'status': None, 'vrf': None}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_invalid_header(self): + input = """ + address,status,vrf,xxx + 192.0.2.1/32,Active,Test VRF,123 + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_missing_required_header(self): + input = """ + status,vrf + Active,Test VRF + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_default_to_field(self): + input = """ + address,status,vrf.name + 192.0.2.1/32,Active,Test VRF + """ + output = ( + {'address': None, 'status': None, 'vrf': 'name'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_pk_to_field(self): + input = """ + address,status,vrf.pk + 192.0.2.1/32,Active,123 + """ + output = ( + {'address': None, 'status': None, 'vrf': 'pk'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_custom_to_field(self): + input = """ + address,status,vrf.rd + 192.0.2.1/32,Active,123:456 + """ + output = ( + {'address': None, 'status': None, 'vrf': 'rd'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_invalid_to_field(self): + input = """ + address,status,vrf.xxx + 192.0.2.1/32,Active,123:456 + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_to_field_on_non_object(self): + input = """ + address,status.foo,vrf + 192.0.2.1/32,Bar,Test VRF + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py index d535443ea..8e85f9e8c 100644 --- a/netbox/utilities/tests/test_ordering.py +++ b/netbox/utilities/tests/test_ordering.py @@ -30,29 +30,32 @@ class NaturalizationTestCase(TestCase): # Original, naturalized data = ( + # IOS/JunOS-style - ('Gi', '9999999999999999Gi000000000000000000'), - ('Gi1', '9999999999999999Gi000001000000000000'), - ('Gi1.0', '9999999999999999Gi000001000000000000'), - ('Gi1.1', '9999999999999999Gi000001000000000001'), - ('Gi1:0', '9999999999999999Gi000001000000000000'), + ('Gi', '9999999999999999Gi..................'), + ('Gi1', '9999999999999999Gi000001............'), + ('Gi1.0', '9999999999999999Gi000001......000000'), + ('Gi1.1', '9999999999999999Gi000001......000001'), + ('Gi1:0', '9999999999999999Gi000001000000......'), ('Gi1:0.0', '9999999999999999Gi000001000000000000'), ('Gi1:0.1', '9999999999999999Gi000001000000000001'), - ('Gi1:1', '9999999999999999Gi000001000001000000'), + ('Gi1:1', '9999999999999999Gi000001000001......'), ('Gi1:1.0', '9999999999999999Gi000001000001000000'), ('Gi1:1.1', '9999999999999999Gi000001000001000001'), - ('Gi1/2', '0001999999999999Gi000002000000000000'), - ('Gi1/2/3', '0001000299999999Gi000003000000000000'), - ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'), - ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'), - ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'), + ('Gi1/2', '0001999999999999Gi000002............'), + ('Gi1/2/3', '0001000299999999Gi000003............'), + ('Gi1/2/3/4', '0001000200039999Gi000004............'), + ('Gi1/2/3/4/5', '0001000200030004Gi000005............'), + ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006......'), ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'), + # Generic - ('Interface 1', '9999999999999999Interface 000001000000000000'), - ('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'), - ('Interface 99', '9999999999999999Interface 000099000000000000'), - ('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'), - ('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'), + ('Interface 1', '9999999999999999Interface 000001............'), + ('Interface 1 (other)', '9999999999999999Interface 000001............ (other)'), + ('Interface 99', '9999999999999999Interface 000099............'), + ('PCIe1-p1', '9999999999999999PCIe000001............-p00000001'), + ('PCIe1-p99', '9999999999999999PCIe000001............-p00000099'), + ) for origin, naturalized in data: diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 446622118..351b1fd68 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -239,3 +239,21 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=None): difference[key] = destination_dict[key] return difference + + +def flatten_dict(d, prefix='', separator='.'): + """ + Flatten netsted dictionaries into a single level by joining key names with a separator. + + :param d: The dictionary to be flattened + :param prefix: Initial prefix (if any) + :param separator: The character to use when concatenating key names + """ + ret = {} + for k, v in d.items(): + key = separator.join([prefix, k]) if prefix else k + if type(v) is dict: + ret.update(flatten_dict(v, prefix=key)) + else: + ret[key] = v + return ret diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 78acefa48..4b5993c5f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,9 @@ +import logging import sys from copy import deepcopy -from django.conf import settings from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError @@ -13,6 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse +from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe @@ -24,11 +26,11 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction -from utilities.forms import BootstrapMixin, CSVDataField +from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm -from .paginator import EnhancedPaginator +from .paginator import EnhancedPaginator, get_paginate_count class GetReturnURLMixin(object): @@ -164,14 +166,18 @@ class ObjectListView(View): permissions[action] = request.user.has_perm(perm_name) # Construct the table based on the user's permissions - table = self.table(self.queryset) + if request.user.is_authenticated: + columns = request.user.config.get(f"tables.{self.table.__name__}.columns") + else: + columns = None + table = self.table(self.queryset, columns=columns) if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') # Apply the request context paginate = { 'paginator_class': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) @@ -180,12 +186,30 @@ class ObjectListView(View): 'table': table, 'permissions': permissions, 'action_buttons': self.action_buttons, + 'table_config_form': TableConfigForm(table=table), 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, } context.update(self.extra_context()) return render(request, self.template_name, context) + @method_decorator(login_required) + def post(self, request): + + # Update the user's table configuration + table = self.table(self.queryset) + form = TableConfigForm(table=table, data=request.POST) + preference_name = f"tables.{self.table.__name__}.columns" + + if form.is_valid(): + if 'set' in request.POST: + request.user.config.set(preference_name, form.cleaned_data['columns'], commit=True) + elif 'clear' in request.POST: + request.user.config.clear(preference_name, commit=True) + messages.success(request, "Your preferences have been updated.") + + return redirect(request.get_full_path()) + def alter_queryset(self, request): # .all() is necessary to avoid caching queries return self.queryset.all() @@ -219,35 +243,36 @@ class ObjectEditView(GetReturnURLMixin, View): # given some parameter from the request URL. return obj - def get(self, request, *args, **kwargs): + def dispatch(self, request, *args, **kwargs): + self.obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) - obj = self.get_object(kwargs) - obj = self.alter_obj(obj, request, args, kwargs) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} - form = self.model_form(instance=obj, initial=initial_data) + form = self.model_form(instance=self.obj, initial=initial_data) return render(request, self.template_name, { - 'obj': obj, + 'obj': self.obj, 'obj_type': self.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, obj), + 'return_url': self.get_return_url(request, self.obj), }) def post(self, request, *args, **kwargs): - - obj = self.get_object(kwargs) - obj = self.alter_obj(obj, request, args, kwargs) - form = self.model_form(request.POST, request.FILES, instance=obj) + logger = logging.getLogger('netbox.views.ObjectEditView') + form = self.model_form(request.POST, request.FILES, instance=self.obj) if form.is_valid(): - obj_created = not form.instance.pk - obj = form.save() + logger.debug("Form validation was successful") + obj = form.save() msg = '{} {}'.format( - 'Created' if obj_created else 'Modified', + 'Created' if not form.instance.pk else 'Modified', self.model._meta.verbose_name ) + logger.info(f"{msg} {obj} (PK: {obj.pk})") if hasattr(obj, 'get_absolute_url'): msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) else: @@ -269,11 +294,14 @@ class ObjectEditView(GetReturnURLMixin, View): else: return redirect(self.get_return_url(request, obj)) + else: + logger.debug("Form validation failed") + return render(request, self.template_name, { - 'obj': obj, + 'obj': self.obj, 'obj_type': self.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, obj), + 'return_url': self.get_return_url(request, self.obj), }) @@ -295,7 +323,6 @@ class ObjectDeleteView(GetReturnURLMixin, View): return get_object_or_404(self.model, pk=kwargs['pk']) def get(self, request, **kwargs): - obj = self.get_object(kwargs) form = ConfirmationForm(initial=request.GET) @@ -307,18 +334,22 @@ class ObjectDeleteView(GetReturnURLMixin, View): }) def post(self, request, **kwargs): - + logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(kwargs) form = ConfirmationForm(request.POST) + if form.is_valid(): + logger.debug("Form validation was successful") try: obj.delete() except ProtectedError as e: + logger.info("Caught ProtectedError while attempting to delete object") handle_protectederror(obj, request, e) return redirect(obj.get_absolute_url()) msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj) + logger.info(msg) messages.success(request, msg) return_url = form.cleaned_data.get('return_url') @@ -327,6 +358,9 @@ class ObjectDeleteView(GetReturnURLMixin, View): else: return redirect(self.get_return_url(request, obj)) + else: + logger.debug("Form validation failed") + return render(request, self.template_name, { 'obj': obj, 'form': form, @@ -350,7 +384,6 @@ class BulkCreateView(GetReturnURLMixin, View): template_name = None def get(self, request): - # Set initial values for visible form fields from query args initial = {} for field in getattr(self.model_form._meta, 'fields', []): @@ -368,13 +401,13 @@ class BulkCreateView(GetReturnURLMixin, View): }) def post(self, request): - + logger = logging.getLogger('netbox.views.BulkCreateView') model = self.model_form._meta.model form = self.form(request.POST) model_form = self.model_form(request.POST) if form.is_valid(): - + logger.debug("Form validation was successful") pattern = form.cleaned_data['pattern'] new_objs = [] @@ -392,6 +425,7 @@ class BulkCreateView(GetReturnURLMixin, View): # Validate each new object independently. if model_form.is_valid(): obj = model_form.save() + logger.debug(f"Created {obj} (PK: {obj.pk})") new_objs.append(obj) else: # Copy any errors on the pattern target field to the pattern form. @@ -403,6 +437,7 @@ class BulkCreateView(GetReturnURLMixin, View): # If we make it to this point, validation has succeeded on all new objects. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) + logger.info(msg) messages.success(request, msg) if '_addanother' in request.POST: @@ -412,6 +447,9 @@ class BulkCreateView(GetReturnURLMixin, View): except IntegrityError: pass + else: + logger.debug("Form validation failed") + return render(request, self.template_name, { 'form': form, 'model_form': model_form, @@ -430,7 +468,6 @@ class ObjectImportView(GetReturnURLMixin, View): template_name = 'utilities/obj_import.html' def get(self, request): - form = ImportForm() return render(request, self.template_name, { @@ -440,9 +477,11 @@ class ObjectImportView(GetReturnURLMixin, View): }) def post(self, request): - + logger = logging.getLogger('netbox.views.ObjectImportView') form = ImportForm(request.POST) + if form.is_valid(): + logger.debug("Import form validation was successful") # Initialize model form data = form.cleaned_data['data'] @@ -463,9 +502,11 @@ class ObjectImportView(GetReturnURLMixin, View): # Save the primary object obj = model_form.save() + logger.debug(f"Created {obj} (PK: {obj.pk})") # Iterate through the related object forms (if any), validating and saving each instance. for field_name, related_object_form in self.related_object_forms.items(): + logger.debug("Processing form for related objects: {related_object_form}") for i, rel_obj_data in enumerate(data.get(field_name, list())): @@ -489,7 +530,7 @@ class ObjectImportView(GetReturnURLMixin, View): pass if not model_form.errors: - + logger.info(f"Import object {obj} (PK: {obj.pk})") messages.success(request, mark_safe('Imported object: {}'.format( obj.get_absolute_url(), obj ))) @@ -504,6 +545,7 @@ class ObjectImportView(GetReturnURLMixin, View): return redirect(self.get_return_url(request, obj)) else: + logger.debug("Model form validation failed") # Replicate model form errors for display for field, errors in model_form.errors.items(): @@ -513,6 +555,9 @@ class ObjectImportView(GetReturnURLMixin, View): else: form.add_error(None, "{}: {}".format(field, err)) + else: + logger.debug("Import form validation failed") + return render(request, self.template_name, { 'form': form, 'obj_type': self.model._meta.verbose_name, @@ -536,11 +581,11 @@ class BulkImportView(GetReturnURLMixin, View): def _import_form(self, *args, **kwargs): - fields = self.model_form().fields.keys() - required_fields = [name for name, field in self.model_form().fields.items() if field.required] - class ImportForm(BootstrapMixin, Form): - csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs)) + csv = CSVDataField( + from_form=self.model_form, + widget=Textarea(attrs=self.widget_attrs) + ) return ImportForm(*args, **kwargs) @@ -560,18 +605,20 @@ class BulkImportView(GetReturnURLMixin, View): }) def post(self, request): - + logger = logging.getLogger('netbox.views.BulkImportView') new_objs = [] form = self._import_form(request.POST) if form.is_valid(): + logger.debug("Form validation was successful") try: - # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - for row, data in enumerate(form.cleaned_data['csv'], start=1): - obj_form = self.model_form(data) + headers, records = form.cleaned_data['csv'] + for row, data in enumerate(records, start=1): + obj_form = self.model_form(data, headers=headers) + if obj_form.is_valid(): obj = self._save_obj(obj_form, request) new_objs.append(obj) @@ -585,6 +632,7 @@ class BulkImportView(GetReturnURLMixin, View): if new_objs: msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) + logger.info(msg) messages.success(request, msg) return render(request, "import_success.html", { @@ -595,6 +643,9 @@ class BulkImportView(GetReturnURLMixin, View): except ValidationError: pass + else: + logger.debug("Form validation failed") + return render(request, self.template_name, { 'form': form, 'fields': self.model_form().fields, @@ -623,7 +674,7 @@ class BulkEditView(GetReturnURLMixin, View): return redirect(self.get_return_url(request)) def post(self, request, **kwargs): - + logger = logging.getLogger('netbox.views.BulkEditView') model = self.queryset.model # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. @@ -636,8 +687,9 @@ class BulkEditView(GetReturnURLMixin, View): if '_apply' in request.POST: form = self.form(model, request.POST) - if form.is_valid(): + if form.is_valid(): + logger.debug("Form validation was successful") custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] standard_fields = [ field for field in form.fields if field not in custom_fields + ['pk'] @@ -677,6 +729,7 @@ class BulkEditView(GetReturnURLMixin, View): obj.full_clean() obj.save() + logger.debug(f"Saved {obj} (PK: {obj.pk})") # Update custom fields obj_type = ContentType.objects.get_for_model(model) @@ -697,6 +750,7 @@ class BulkEditView(GetReturnURLMixin, View): ) cfv.value = form.cleaned_data[name] cfv.save() + logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})") # Add/remove tags if form.cleaned_data.get('add_tags', None): @@ -708,6 +762,7 @@ class BulkEditView(GetReturnURLMixin, View): if updated_count: msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) + logger.info(msg) messages.success(self.request, msg) return redirect(self.get_return_url(request)) @@ -715,6 +770,9 @@ class BulkEditView(GetReturnURLMixin, View): except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) + else: + logger.debug("Form validation failed") + else: # Include the PK list as initial data for the form initial_data = {'pk': pk_list} @@ -761,7 +819,7 @@ class BulkDeleteView(GetReturnURLMixin, View): return redirect(self.get_return_url(request)) def post(self, request, **kwargs): - + logger = logging.getLogger('netbox.views.BulkDeleteView') model = self.queryset.model # Are we deleting *all* objects in the queryset or just a selected subset? @@ -778,19 +836,25 @@ class BulkDeleteView(GetReturnURLMixin, View): if '_confirm' in request.POST: form = form_cls(request.POST) if form.is_valid(): + logger.debug("Form validation was successful") # Delete objects queryset = model.objects.filter(pk__in=pk_list) try: deleted_count = queryset.delete()[1][model._meta.label] except ProtectedError as e: + logger.info("Caught ProtectedError while attempting to delete objects") handle_protectederror(list(queryset), request, e) return redirect(self.get_return_url(request)) msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural) + logger.info(msg) messages.success(request, msg) return redirect(self.get_return_url(request)) + else: + logger.debug("Form validation failed") + else: form = form_cls(initial={ 'pk': pk_list, @@ -814,12 +878,12 @@ class BulkDeleteView(GetReturnURLMixin, View): """ Provide a standard bulk delete form if none has been specified for the view """ - class BulkDeleteForm(ConfirmationForm): pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) if self.form: return self.form + return BulkDeleteForm @@ -908,7 +972,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View): template_name = 'utilities/obj_bulk_add_component.html' def post(self, request): - + logger = logging.getLogger('netbox.views.BulkComponentCreateView') parent_model_name = self.parent_model._meta.verbose_name_plural model_name = self.model._meta.verbose_name_plural @@ -926,38 +990,53 @@ class BulkComponentCreateView(GetReturnURLMixin, View): if '_create' in request.POST: form = self.form(request.POST) + if form.is_valid(): + logger.debug("Form validation was successful") new_components = [] data = deepcopy(form.cleaned_data) - for obj in data['pk']: - names = data['name_pattern'] - for name in names: - component_data = { - self.parent_field: obj.pk, - 'name': name, - } - component_data.update(data) - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - for e in errors: - form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + try: + with transaction.atomic(): + + for obj in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + self.parent_field: obj.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + instance = component_form.save() + logger.debug(f"Created {instance} on {instance.parent}") + new_components.append(instance) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + + except IntegrityError: + pass if not form.errors: - self.model.objects.bulk_create(new_components) - - messages.success(request, "Added {} {} to {} {}.".format( + msg = "Added {} {} to {} {}.".format( len(new_components), model_name, len(form.cleaned_data['pk']), parent_model_name - )) + ) + logger.info(msg) + messages.success(request, msg) + return redirect(self.get_return_url(request)) + else: + logger.debug("Form validation failed") + else: form = self.form(initial={'pk': pk_list}) diff --git a/netbox/vapor/filters.py b/netbox/vapor/filters.py index 5d86e53ef..f0c81d2d5 100644 --- a/netbox/vapor/filters.py +++ b/netbox/vapor/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, MultiValueNumberFilter +from utilities.filters import NameSlugSearchFilterSet, TagFilter, MultiValueNumberFilter from tenancy.models import Tenant, TenantGroup from dcim.models import Site, Device, DeviceRole, Interface from dcim.choices import ( @@ -12,10 +12,6 @@ from dcim.filters import MultiValueMACAddressFilter class CustomerFilter(CustomFieldFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -41,7 +37,7 @@ class CustomerFilter(CustomFieldFilterSet): class Meta: model = Tenant - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index a294cdb6f..3cca95b22 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -24,7 +24,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer): class Meta: model = ClusterType - fields = ['id', 'name', 'slug', 'cluster_count'] + fields = ['id', 'name', 'slug', 'description', 'cluster_count'] class ClusterGroupSerializer(ValidatedModelSerializer): @@ -32,7 +32,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer): class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug', 'cluster_count'] + fields = ['id', 'name', 'slug', 'description', 'cluster_count'] class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index a94e043b2..c237f1e68 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -14,9 +14,6 @@ class VirtualizationRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = VirtualizationRootView -# Field choices -router.register('_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice') - # Clusters router.register('cluster-types', views.ClusterTypeViewSet) router.register('cluster-groups', views.ClusterGroupViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 415fc6289..2a1d7c3a9 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -2,24 +2,13 @@ from django.db.models import Count from dcim.models import Device, Interface from extras.api.views import CustomFieldModelViewSet -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers -# -# Field choices -# - -class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.VirtualMachineSerializer, ['status']), - (serializers.InterfaceSerializer, ['type']), - ) - - # # Clusters # diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 59f09c401..a54b6ab28 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -4,9 +4,8 @@ from django.db.models import Q from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet -from tenancy.models import Tenant from utilities.filters import ( - BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) from .choices import * @@ -25,21 +24,17 @@ class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', @@ -91,7 +86,7 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cr class Meta: model = Cluster - fields = ['name'] + fields = ['id', 'name'] def search(self, queryset, name, value): if not value.strip(): @@ -109,10 +104,6 @@ class VirtualMachineFilterSet( CustomFieldFilterSet, CreatedUpdatedFilterSet ): - id__in = NumericInFilter( - field_name='id', - lookup_expr='in' - ) q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 0dbe38324..2f2ee4950 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,6 +1,5 @@ from django import forms from django.core.exceptions import ValidationError -from taggit.forms import TagField from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN @@ -8,14 +7,16 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, + TagField, ) -from ipam.models import IPAddress, VLANGroup, VLAN +from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, + CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -31,19 +32,16 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterType fields = [ - 'name', 'slug', + 'name', 'slug', 'description', ] -class ClusterTypeCSVForm(forms.ModelForm): +class ClusterTypeCSVForm(CSVModelForm): slug = SlugField() class Meta: model = ClusterType fields = ClusterType.csv_headers - help_texts = { - 'name': 'Name of cluster type', - } # @@ -56,19 +54,16 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterGroup fields = [ - 'name', 'slug', + 'name', 'slug', 'description', ] -class ClusterGroupCSVForm(forms.ModelForm): +class ClusterGroupCSVForm(CSVModelForm): slug = SlugField() class Meta: model = ClusterGroup fields = ClusterGroup.csv_headers - help_texts = { - 'name': 'Name of cluster group', - } # @@ -77,24 +72,15 @@ class ClusterGroupCSVForm(forms.ModelForm): class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): type = DynamicModelChoiceField( - queryset=ClusterType.objects.all(), - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + queryset=ClusterType.objects.all() ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -109,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ClusterCSVForm(CustomFieldModelCSVForm): - type = forms.ModelChoiceField( + type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', - help_text='Name of cluster type', - error_messages={ - 'invalid_choice': 'Invalid cluster type name.', - } + help_text='Type of cluster' ) - group = forms.ModelChoiceField( + group = CSVModelChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='name', required=False, - help_text='Name of cluster group', - error_messages={ - 'invalid_choice': 'Invalid cluster group name.', - } + help_text='Assigned cluster group' ) - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', required=False, - help_text='Name of assigned site', - error_messages={ - 'invalid_choice': 'Invalid site name.', - } + help_text='Assigned site' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Invalid tenant name' - } + help_text='Assigned tenant' ) class Meta: @@ -157,31 +131,19 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + required=False ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField( widget=SmallTextarea, @@ -205,7 +167,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-types/", value_field='slug', ) ) @@ -214,7 +175,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -226,7 +186,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field='slug', null_option=True, ) @@ -236,7 +195,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-groups/", value_field='slug', null_option=True, ) @@ -249,7 +207,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Region.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/regions/", filter_for={ "site": "region_id", }, @@ -262,7 +219,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ "rack": "site_id", "devices": "site_id", @@ -273,7 +229,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/', filter_for={ "devices": "rack_id" }, @@ -285,7 +240,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), widget=APISelectMultiple( - api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='cluster' ) @@ -334,7 +288,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( - api_url='/api/virtualization/cluster-groups/', filter_for={ "cluster": "group_id", }, @@ -344,16 +297,12 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) ) cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + queryset=Cluster.objects.all() ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -361,10 +310,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) tags = TagField( required=False @@ -408,7 +354,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ip_choices = [(None, '---------')] # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( - family=family, interface__virtual_machine=self.instance + address__family=family, interface__virtual_machine=self.instance ) if interface_ips: ip_choices.append( @@ -418,7 +364,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - family=family, nat_inside__interface__virtual_machine=self.instance + address__family=family, nat_inside__interface__virtual_machine=self.instance ) if nat_ips: ip_choices.append( @@ -443,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm): required=False, help_text='Operational status of device' ) - cluster = forms.ModelChoiceField( + cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', - help_text='Name of parent cluster', - error_messages={ - 'invalid_choice': 'Invalid cluster name.', - } + help_text='Assigned cluster' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True ), required=False, to_field_name='name', - help_text='Name of functional role', - error_messages={ - 'invalid_choice': 'Invalid role name.' - } + help_text='Functional role' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.' - } + help_text='Assigned tenant' ) - platform = forms.ModelChoiceField( + platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned platform', - error_messages={ - 'invalid_choice': 'Invalid platform.', - } + help_text='Assigned platform' ) class Meta: @@ -499,10 +433,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False, - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + required=False ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -510,7 +441,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -518,17 +448,11 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) vcpus = forms.IntegerField( required=False, @@ -568,7 +492,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) @@ -578,7 +501,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) @@ -586,17 +508,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster', - widget=APISelectMultiple( - api_url='/api/virtualization/clusters/', - ) + label='Cluster' ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field="slug", filter_for={ 'site': 'region' @@ -608,7 +526,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/sites/', value_field="slug", null_option=True, ) @@ -618,7 +535,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/device-roles/', value_field="slug", null_option=True, additional_query_params={ @@ -636,7 +552,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/platforms/', value_field="slug", null_option=True, ) @@ -657,7 +572,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -669,7 +583,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -766,7 +679,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -778,7 +690,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -836,7 +747,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -848,7 +758,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -889,24 +798,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): label='Name' ) + def clean_tags(self): + # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we + # must first convert the list of tags to a string. + return ','.join(self.cleaned_data.get('tags')) -class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): + +class InterfaceBulkCreateForm( + form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), + VirtualMachineBulkAddComponentForm +): type = forms.ChoiceField( choices=VMInterfaceTypeChoices, initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, widget=forms.HiddenInput() ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - description = forms.CharField( - max_length=100, - required=False - ) diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py deleted file mode 100644 index 4a8fa4ea5..000000000 --- a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py +++ /dev/null @@ -1,89 +0,0 @@ -import django.contrib.postgres.fields.jsonb -import django.db.models.deletion -import taggit.managers -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [('virtualization', '0002_virtualmachine_add_status'), ('virtualization', '0003_cluster_add_site'), ('virtualization', '0004_virtualmachine_add_role'), ('virtualization', '0005_django2'), ('virtualization', '0006_tags'), ('virtualization', '0007_change_logging'), ('virtualization', '0008_virtualmachine_local_context_data'), ('virtualization', '0009_custom_tag_models')] - - dependencies = [ - ('dcim', '0044_virtualization'), - ('virtualization', '0001_virtualization'), - ('extras', '0019_tag_taggeditem'), - ('taggit', '0002_auto_20150616_2121'), - ] - - operations = [ - migrations.AddField( - model_name='virtualmachine', - name='status', - field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'), - ), - migrations.AddField( - model_name='cluster', - name='site', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'), - ), - migrations.AddField( - model_name='virtualmachine', - name='role', - field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), - ), - migrations.AddField( - model_name='clustergroup', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='clustergroup', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='clustertype', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='clustertype', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AlterField( - model_name='cluster', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='cluster', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AlterField( - model_name='virtualmachine', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='virtualmachine', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='virtualmachine', - name='local_context_data', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), - ), - migrations.AddField( - model_name='cluster', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'), - ), - migrations.AddField( - model_name='virtualmachine', - name='tags', - field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'), - ), - ] diff --git a/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py b/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py deleted file mode 100644 index eb7abd362..000000000 --- a/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py +++ /dev/null @@ -1,50 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - -VIRTUALMACHINE_STATUS_CHOICES = ( - (0, 'offline'), - (1, 'active'), - (3, 'staged'), -) - - -def virtualmachine_status_to_slug(apps, schema_editor): - VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') - for id, slug in VIRTUALMACHINE_STATUS_CHOICES: - VirtualMachine.objects.filter(status=str(id)).update(status=slug) - - -class Migration(migrations.Migration): - - replaces = [('virtualization', '0010_cluster_add_tenant'), ('virtualization', '0011_3569_virtualmachine_fields'), ('virtualization', '0012_vm_name_nonunique')] - - dependencies = [ - ('tenancy', '0001_initial'), - ('tenancy', '0006_custom_tag_models'), - ('virtualization', '0009_custom_tag_models'), - ] - - operations = [ - migrations.AddField( - model_name='cluster', - name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='tenancy.Tenant'), - ), - migrations.AlterField( - model_name='virtualmachine', - name='name', - field=models.CharField(max_length=64), - ), - migrations.AlterUniqueTogether( - name='virtualmachine', - unique_together={('cluster', 'tenant', 'name')}, - ), - migrations.AlterField( - model_name='virtualmachine', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=virtualmachine_status_to_slug, - ), - ] diff --git a/netbox/virtualization/migrations/0014_standardize_description.py b/netbox/virtualization/migrations/0014_standardize_description.py new file mode 100644 index 000000000..e02655bb7 --- /dev/null +++ b/netbox/virtualization/migrations/0014_standardize_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0013_deterministic_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='clustertype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..3daeff013 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .choices import * @@ -34,8 +35,12 @@ class ClusterType(ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'description'] class Meta: ordering = ['name'] @@ -50,6 +55,7 @@ class ClusterType(ChangeLoggedModel): return ( self.name, self.slug, + self.description, ) @@ -68,8 +74,12 @@ class ClusterGroup(ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'description'] class Meta: ordering = ['name'] @@ -84,6 +94,7 @@ class ClusterGroup(ChangeLoggedModel): return ( self.name, self.slug, + self.description, ) @@ -91,6 +102,7 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -177,6 +189,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index fdb997dab..077add945 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, TagColumn, ToggleColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine CLUSTERTYPE_ACTIONS = """ @@ -46,7 +46,9 @@ VIRTUALMACHINE_PRIMARY_IP = """ class ClusterTypeTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - cluster_count = tables.Column(verbose_name='Clusters') + cluster_count = tables.Column( + verbose_name='Clusters' + ) actions = tables.TemplateColumn( template_code=CLUSTERTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, @@ -55,7 +57,8 @@ class ClusterTypeTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'name', 'cluster_count', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') # @@ -65,7 +68,9 @@ class ClusterTypeTable(BaseTable): class ClusterGroupTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - cluster_count = tables.Column(verbose_name='Clusters') + cluster_count = tables.Column( + verbose_name='Clusters' + ) actions = tables.TemplateColumn( template_code=CLUSTERGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, @@ -74,7 +79,8 @@ class ClusterGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'name', 'cluster_count', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') # @@ -84,14 +90,32 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices') - vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs') + tenant = tables.LinkColumn( + viewname='tenancy:tenant', + args=[Accessor('tenant.slug')] + ) + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site.slug')] + ) + device_count = tables.Column( + accessor=Accessor('devices.count'), + orderable=False, + verbose_name='Devices' + ) + vm_count = tables.Column( + accessor=Accessor('virtual_machines.count'), + orderable=False, + verbose_name='VMs' + ) + tags = TagColumn( + url_name='virtualization:cluster_list' + ) class Meta(BaseTable.Meta): model = Cluster - fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count', 'tags') + default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') # @@ -101,10 +125,19 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS) - cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')]) - role = tables.TemplateColumn(VIRTUALMACHINE_ROLE) - tenant = tables.TemplateColumn(template_code=COL_TENANT) + status = tables.TemplateColumn( + template_code=VIRTUALMACHINE_STATUS + ) + cluster = tables.LinkColumn( + viewname='virtualization:cluster', + args=[Accessor('cluster.pk')] + ) + role = tables.TemplateColumn( + template_code=VIRTUALMACHINE_ROLE + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) class Meta(BaseTable.Meta): model = VirtualMachine @@ -112,13 +145,34 @@ class VirtualMachineTable(BaseTable): class VirtualMachineDetailTable(VirtualMachineTable): + primary_ip4 = tables.LinkColumn( + viewname='ipam:ipaddress', + args=[Accessor('primary_ip4.pk')], + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.LinkColumn( + viewname='ipam:ipaddress', + args=[Accessor('primary_ip6.pk')], + verbose_name='IPv6 Address' + ) primary_ip = tables.TemplateColumn( - orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP + orderable=False, + verbose_name='IP Address', + template_code=VIRTUALMACHINE_PRIMARY_IP + ) + tags = TagColumn( + url_name='virtualization:virtualmachine_list' ) class Meta(BaseTable.Meta): model = VirtualMachine - fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip') + fields = ( + 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', + 'primary_ip6', 'primary_ip', 'tags', + ) + default_columns = ( + 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + ) # diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 719954c10..8568e21e9 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from dcim.models import Interface from ipam.models import IPAddress, VLAN -from utilities.testing import APITestCase, choices_to_dict, disable_warnings +from utilities.testing import APITestCase, disable_warnings from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -19,19 +19,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('virtualization-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # VirtualMachine - self.assertEqual(choices_to_dict(response.data.get('virtual-machine:status')), VirtualMachineStatusChoices.as_dict()) - - # Interface - self.assertEqual(choices_to_dict(response.data.get('interface:type')), VMInterfaceTypeChoices.as_dict()) - class ClusterTypeTest(APITestCase): @@ -501,6 +488,18 @@ class VirtualMachineTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_cluster_constraint(self): + + data = { + 'name': 'Test Virtual Machine 1', + 'cluster': self.cluster1.pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class InterfaceTest(APITestCase): diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index 36595eb19..51c7c6e8d 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -15,15 +15,14 @@ class ClusterTypeTestCase(TestCase): def setUpTestData(cls): cluster_types = ( - ClusterType(name='Cluster Type 1', slug='cluster-type-1'), - ClusterType(name='Cluster Type 2', slug='cluster-type-2'), - ClusterType(name='Cluster Type 3', slug='cluster-type-3'), + ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='A'), + ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='B'), + ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='C'), ) ClusterType.objects.bulk_create(cluster_types) def test_id(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id': [str(id) for id in id_list]} + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): @@ -34,6 +33,10 @@ class ClusterTypeTestCase(TestCase): params = {'slug': ['cluster-type-1', 'cluster-type-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ClusterGroupTestCase(TestCase): queryset = ClusterGroup.objects.all() @@ -43,15 +46,14 @@ class ClusterGroupTestCase(TestCase): def setUpTestData(cls): cluster_groups = ( - ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), - ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), - ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='A'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='B'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='C'), ) ClusterGroup.objects.bulk_create(cluster_groups) def test_id(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id': [str(id) for id in id_list]} + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): @@ -62,6 +64,10 @@ class ClusterGroupTestCase(TestCase): params = {'slug': ['cluster-group-1', 'cluster-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ClusterTestCase(TestCase): queryset = Cluster.objects.all() @@ -105,7 +111,8 @@ class ClusterTestCase(TestCase): TenantGroup(name='Tenant group 2', slug='tenant-group-2'), TenantGroup(name='Tenant group 3', slug='tenant-group-3'), ) - TenantGroup.objects.bulk_create(tenant_groups) + for tenantgroup in tenant_groups: + tenantgroup.save() tenants = ( Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), @@ -121,13 +128,12 @@ class ClusterTestCase(TestCase): ) Cluster.objects.bulk_create(clusters) - def test_name(self): - params = {'name': ['Cluster 1', 'Cluster 2']} + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_id__in(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id__in': ','.join([str(id) for id in id_list])} + def test_name(self): + params = {'name': ['Cluster 1', 'Cluster 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): @@ -236,7 +242,8 @@ class VirtualMachineTestCase(TestCase): TenantGroup(name='Tenant group 2', slug='tenant-group-2'), TenantGroup(name='Tenant group 3', slug='tenant-group-3'), ) - TenantGroup.objects.bulk_create(tenant_groups) + for tenantgroup in tenant_groups: + tenantgroup.save() tenants = ( Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), @@ -260,8 +267,7 @@ class VirtualMachineTestCase(TestCase): Interface.objects.bulk_create(interfaces) def test_id(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id': [str(id) for id in id_list]} + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): @@ -280,11 +286,6 @@ class VirtualMachineTestCase(TestCase): params = {'disk': [1, 2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_id__in(self): - id_list = self.queryset.values_list('id', flat=True)[:2] - params = {'id__in': ','.join([str(id) for id in id_list])} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_status(self): params = {'status': [VirtualMachineStatusChoices.STATUS_ACTIVE, VirtualMachineStatusChoices.STATUS_STAGED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 639908977..e7bb19285 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -23,13 +23,14 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', + 'description': 'A new cluster group', } cls.csv_data = ( - "name,slug", - "Cluster Group 4,cluster-group-4", - "Cluster Group 5,cluster-group-5", - "Cluster Group 6,cluster-group-6", + "name,slug,description", + "Cluster Group 4,cluster-group-4,Fourth cluster group", + "Cluster Group 5,cluster-group-5,Fifth cluster group", + "Cluster Group 6,cluster-group-6,Sixth cluster group", ) @@ -48,13 +49,14 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', + 'description': 'A new cluster type', } cls.csv_data = ( - "name,slug", - "Cluster Type 4,cluster-type-4", - "Cluster Type 5,cluster-type-5", - "Cluster Type 6,cluster-type-6", + "name,slug,description", + "Cluster Type 4,cluster-type-4,Fourth cluster type", + "Cluster Type 5,cluster-type-5,Fifth cluster type", + "Cluster Type 6,cluster-type-6,Sixth cluster type", ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 291392eb4..0a05833f4 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -168,7 +168,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): def get(self, request, pk): cluster = get_object_or_404(Cluster, pk=pk) - form = self.form(cluster) + form = self.form(cluster, initial=request.GET) return render(request, self.template_name, { 'cluster': cluster, @@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC permission_required = 'dcim.add_interface' parent_model = VirtualMachine parent_field = 'virtual_machine' - form = forms.VirtualMachineBulkAddInterfaceForm + form = forms.InterfaceBulkCreateForm model = Interface model_form = forms.InterfaceForm filterset = filters.VirtualMachineFilterSet diff --git a/requirements.txt b/requirements.txt index 3e7687f1a..f4c2514d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,26 @@ -Django>=2.2,<2.3 +Django>=3.0,<3.1 django-allauth==0.41.0 django-cacheops==4.2 django-cors-headers==3.2.1 -django-debug-toolbar==2.1 +django-debug-toolbar==2.2 django-filter==2.2.0 -django-mptt==0.9.1 +django-mptt==0.11.0 django-pglocks==1.0.4 -django-prometheus==1.1.0 -django-rq==2.2.0 -django-tables2==2.2.1 +django-prometheus==2.0.0 +django-rq==2.3.2 +django-tables2==2.3.1 django-taggit==1.2.0 django-taggit-serializer==0.1.7 django-timezone-field==4.0 -djangorestframework==3.10.3 -drf-yasg[validation]==1.17.0 +djangorestframework==3.11.0 +drf-yasg[validation]==1.17.1 gunicorn==20.0.4 -Jinja2==2.10.3 +Jinja2==2.11.1 Markdown==3.2.1 netaddr==0.7.19 -Pillow==7.0.0 -psycopg2-binary==2.8.4 -pycryptodome==3.9.4 -PyYAML==5.3 -redis==3.3.11 -svgwrite==1.3.1 +Pillow==7.1.1 +psycopg2-binary==2.8.5 +pycryptodome==3.9.7 +PyYAML==5.3.1 +redis==3.4.1 +svgwrite==1.4 diff --git a/scripts/cibuild.sh b/scripts/cibuild.sh index 282000b0a..6a0422308 100755 --- a/scripts/cibuild.sh +++ b/scripts/cibuild.sh @@ -34,11 +34,8 @@ if [[ $RC != 0 ]]; then EXIT=$RC fi -# Prepare configuration file for use in CI -CONFIG="netbox/netbox/configuration.py" -cp netbox/netbox/configuration.example.py $CONFIG -sed -i -e "s/ALLOWED_HOSTS = \[\]/ALLOWED_HOSTS = \['*'\]/g" $CONFIG -sed -i -e "s/SECRET_KEY = ''/SECRET_KEY = 'netboxci'/g" $CONFIG +# Point to the testing configuration file for use in CI +ln -s configuration.testing.py netbox/netbox/configuration.py # Run NetBox tests coverage run --source="netbox/" netbox/manage.py test netbox/