diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b01cb6dc8..38a6fd550 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,7 +5,9 @@ about: Report a reproducible bug in the current release of NetBox --- ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: @@ -14,5 +16,13 @@ about: Suggest an addition or modification to the NetBox documentation [ ] Deprecation [ ] Cleanup (formatting, typos, etc.) +### Area +[ ] Installation instructions +[ ] Configuration parameters +[ ] Functionality/features +[ ] REST API +[ ] Administration/development +[ ] Other + ### Proposed Changes diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index ebe19d811..2f742d416 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,7 +5,9 @@ about: Propose a new NetBox feature or enhancement --- ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: ### Proposed Changes diff --git a/.github/stale.yml b/.github/stale.yml index 61201cc4e..43401de8a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,5 +1,8 @@ # Configuration for Stale (https://github.com/apps/stale) +# Pull requests are exempt from being marked as stale +only: issues + # Number of days of inactivity before an issue becomes stale daysUntilStale: 14 diff --git a/base_requirements.txt b/base_requirements.txt index 8b42c835d..ed42b6c08 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -22,6 +22,10 @@ django-filter # https://github.com/django-mptt/django-mptt django-mptt +# Context managers for PostgreSQL advisory locks +# https://github.com/Xof/django-pglocks +django-pglocks + # Prometheus metrics library for Django # https://github.com/korfuri/django-prometheus django-prometheus diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 6fac5b63d..cf98a6290 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -177,10 +177,11 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a All variables support the following default options: -* `label` - The name of the form field -* `description` - A brief description of the field * `default` - The field's default value +* `description` - A brief description of the field +* `label` - The name of the form field * `required` - Indicates whether the field is mandatory (default: true) +* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/)) ## Example diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index bc79e90ab..cbe01728c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -90,6 +90,14 @@ This setting enables debugging. This should be done only during development or t --- +## DEVELOPER + +Default: False + +This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. + +--- + ## EMAIL In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting: @@ -101,6 +109,20 @@ In order to send email, NetBox needs an email server configured. The following i * TIMEOUT - Amount of time to wait for a connection (seconds) * FROM_EMAIL - Sender address for emails sent by NetBox +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): + +``` +# python ./manage.py nbshell +>>> from django.core.mail import send_mail +>>> send_mail( + 'Test Email Subject', + 'Test Email Body', + 'noreply-netbox@example.com', + ['users@example.com'], + fail_silently=False +) +``` + --- ## EXEMPT_VIEW_PERMISSIONS @@ -127,7 +149,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- -# ENFORCE_GLOBAL_UNIQUE +## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dd7492cb4..e86b2810a 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv * `PASSWORD` - PostgreSQL password * `HOST` - Name or IP address of the database server (use `localhost` if running locally) * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432) -* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)). +* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended) Example: @@ -36,6 +36,9 @@ DATABASE = { } ``` +!!! note + NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). + --- ## REDIS @@ -77,14 +80,56 @@ REDIS = { } ``` -!!! note: +!!! note 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 -!!! warning: +!!! 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. +### Using Redis Sentinel + +If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal +configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from +above and the addition of two new keys. + +* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address +of the Redis server and port for each sentinel instance to connect to +* `SENTINEL_SERVICE`: Name of the master / service to connect to + +Example: + +```python +REDIS = { + 'webhooks': { + 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + }, + 'caching': { + 'SENTINELS': [ + ('mysentinel.redis.example.com', 6379), + ('othersentinel.redis.example.com', 6379) + ], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + } +} +``` + +!!! 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 + `SENTINELS`/`SENTINEL_SERVICE`. + + --- ## SECRET_KEY diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 4c490eebf..53b2215b3 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/ The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. -If there's a strong case for introducing a new depdency, it must meet the following criteria: +If there's a strong case for introducing a new dependency, it must meet the following criteria: * Its complete source code must be published and freely accessible without registration. * Its license must be conducive to inclusion in an open source project. @@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. +* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. + * No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely. * Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. -* Every model should have a docstring. Every custom method should include an expalantion of its function. +* Every model should have a docstring. Every custom method should include an explanation of its function. * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. + +## Branding + +* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation. + +* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index 5e19f54a2..cc1065fef 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -107,9 +107,10 @@ Install gunicorn: # pip3 install gunicorn ``` -Copy `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. +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. ```no-highlight +# cd /opt/netbox # cp contrib/gunicorn.py /opt/netbox/gunicorn.py ``` diff --git a/docs/installation/index.md b/docs/installation/index.md index 4962eb7d0..59631bf7a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) 2. [NetBox components](2-netbox.md) -3. [HTTP dameon](3-http-daemon.md) +3. [HTTP daemon](3-http-daemon.md) 4. [LDAP authentication](4-ldap.md) (optional) # Upgrading diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index 6199b5511..f5fcb7598 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -12,84 +12,19 @@ Migration is not required, as supervisord will still continue to function. ### systemd configuration: -Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service +We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: ```no-highlight -# cp contrib/netbox.service /etc/systemd/system/netbox.service -# cp contrib/netbox-rq.service /etc/systemd/system/netbox-rq.service +# cp contrib/*.service /etc/systemd/system/ ``` -Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`). If using CentOS/RHEL. Change the username from `www-data` to `nginx` or `apache`: +!!! note + These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files. -```no-highlight -/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi -``` +!!! note + You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames. -```no-highlight -User=www-data -Group=www-data -``` - -Copy contrib/netbox.env to /etc/sysconfig/netbox.env - -```no-highlight -# cp contrib/netbox.env /etc/sysconfig/netbox.env -``` - -Edit /etc/sysconfig/netbox.env and change the settings as required. Update the `WorkingDirectory` variable if needed. - -```no-highlight -# Name is the Process Name -# -Name = 'Netbox' - -# ConfigPath is the path to the gunicorn config file. -# -ConfigPath=/opt/netbox/gunicorn.conf - -# WorkingDirectory is the Working Directory for Netbox. -# -WorkingDirectory=/opt/netbox/ - -# PidPath is the path to the pid for the netbox WSGI -# -PidPath=/var/run/netbox.pid -``` - -Copy contrib/gunicorn.conf to gunicorn.conf - -```no-highlight -# cp contrib/gunicorn.conf to gunicorn.conf -``` - -Edit gunicorn.conf and change the settings as required. - -``` -# Bind is the ip and port that the Netbox WSGI should bind to -# -bind='127.0.0.1:8001' - -# Workers is the number of workers that GUnicorn should spawn. -# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17. -# -workers=3 - -# Threads -# The number of threads for handling requests -# -threads=3 - -# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error) -# -timeout=120 - -# ErrorLog -# ErrorLog is the logfile for the ErrorLog -# -errorlog='/opt/netbox/netbox.log' -``` - -Finally, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: +Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: ```no-highlight # systemctl daemon-reload @@ -98,3 +33,25 @@ Finally, start the `netbox` and `netbox-rq` services and enable them to initiate # systemctl enable netbox.service # systemctl enable netbox-rq.service ``` + +You can use the command `systemctl status netbox` to verify that the WSGI service is running: + +``` +# systemctl status netbox.service +● netbox.service - NetBox WSGI Service + Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) + Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago + Docs: https://netbox.readthedocs.io/en/stable/ + Main PID: 11993 (gunicorn) + Tasks: 6 (limit: 2362) + CGroup: /system.slice/netbox.service + ├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... + ├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... + ├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... +... +``` + +At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. + +!!! info + Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6a2c0188f..e5cf93a28 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui ```no-highlight # sudo systemctl restart netbox -# sudo systemctl restart netbox-rqworker +# sudo systemctl restart netbox-rq ``` !!! note diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 68487ebb8..d3b889513 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,9 +1,81 @@ -# v2.7.4 (FUTURE) +# v2.7.7 (FUTURE) + +## Enhancements + +* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment +* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings ## Bug Fixes +* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API +* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine + +--- + +# v2.7.6 (2020-02-13) + +## Bug Fixes + +* [#4166](https://github.com/netbox-community/netbox/issues/4166) - Fix schema migrations to enforce maximum character length for naturalized fields + +--- + +# v2.7.5 (2020-02-13) + +**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox. + +## Enhancements + +* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable +* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components +* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel +* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines +* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views +* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components +* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views +* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components + +## Bug Fixes + +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices +* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens +* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests +* [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional +* [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view +* [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form +* [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list +* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type +* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit +* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams +* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption +* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts +* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration + +--- + +# v2.7.4 (2020-02-04) + +## Enhancements + +* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV +* [#2921](https://github.com/netbox-community/netbox/issues/2921) - Replace tags filter with Select2 widget +* [#3313](https://github.com/netbox-community/netbox/issues/3313) - Toggle config context display between JSON and YAML +* [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group +* [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command + +## Bug Fixes + +* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer +* [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines +* [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) +* [#4067](https://github.com/netbox-community/netbox/issues/4067) - Correct permission checked when creating a rack (vs. editing) +* [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view +* [#4079](https://github.com/netbox-community/netbox/issues/4079) - Fix assignment of power panel when bulk editing power feeds +* [#4084](https://github.com/netbox-community/netbox/issues/4084) - Fix exception when creating an interface with tagged VLANs --- diff --git a/mkdocs.yml b/mkdocs.yml index 86cf9fead..4ba91dfe5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,7 +41,6 @@ pages: - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' - - Topology Maps: 'additional-features/topology-maps.md' - Webhooks: 'additional-features/webhooks.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b9d1b439b..cd3015d0a 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -15,15 +15,15 @@ router = routers.DefaultRouter() router.APIRootView = CircuitsRootView # Field choices -router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') # Providers -router.register(r'providers', views.ProviderViewSet) +router.register('providers', views.ProviderViewSet) # Circuits -router.register(r'circuit-types', views.CircuitTypeViewSet) -router.register(r'circuits', views.CircuitViewSet) -router.register(r'circuit-terminations', views.CircuitTerminationViewSet) +router.register('circuit-types', views.CircuitTypeViewSet) +router.register('circuits', views.CircuitViewSet) +router.register('circuit-terminations', views.CircuitTerminationViewSet) app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 165e32eb3..0b0378a7a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,12 +2,15 @@ from django import forms from taggit.forms import TagField from dcim.models import Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, +) from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, - DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple + APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -17,7 +20,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderForm(BootstrapMixin, CustomFieldForm): +class ProviderForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() tags = TagField( @@ -46,7 +49,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderCSVForm(forms.ModelForm): +class ProviderCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -105,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -117,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -129,6 +133,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='ASN' ) + tag = TagFilterField(model) # @@ -160,7 +165,19 @@ class CircuitTypeCSVForm(forms.ModelForm): # Circuits # -class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + widget=APISelect( + api_url="/api/circuits/providers/" + ) + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all(), + widget=APISelect( + api_url="/api/circuits/circuit-types/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -177,18 +194,12 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'commit_rate': "Committed rate", } widgets = { - 'provider': APISelect( - api_url="/api/circuits/providers/" - ), - 'type': APISelect( - api_url="/api/circuits/circuit-types/" - ), 'status': StaticSelect2(), 'install_date': DatePicker(), } -class CircuitCSVForm(forms.ModelForm): +class CircuitCSVForm(CustomFieldModelCSVForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -232,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False, widget=APISelect( api_url="/api/circuits/circuit-types/" ) ) - provider = forms.ModelChoiceField( + provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, widget=APISelect( @@ -252,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit initial='', widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -287,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label='Search' ) - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/circuit-types/", value_field="slug", ) ) - provider = FilterChoiceField( + provider = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/providers/", value_field="slug", @@ -308,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -320,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -333,6 +347,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm min_value=0, label='Commit rate (Kbps)' ) + tag = TagFilterField(model) # diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 576437ef1..9cc7af6ae 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,23 +1,15 @@ -import urllib.parse - -from django.test import Client, TestCase -from django.urls import reverse +import datetime +from circuits.choices import * from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import create_test_user +from utilities.testing import ViewTestCases -class ProviderTestCase(TestCase): +class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Provider - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_provider', - 'circuits.add_provider', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), @@ -25,48 +17,40 @@ class ProviderTestCase(TestCase): Provider(name='Provider 3', slug='provider-3', asn=65003), ]) - def test_provider_list(self): - - url = reverse('circuits:provider_list') - params = { - "q": "test", + cls.form_data = { + 'name': 'Provider X', + 'slug': 'provider-x', + 'asn': 65123, + 'account': '1234', + 'portal_url': 'http://example.com/portal', + 'noc_contact': 'noc@example.com', + 'admin_contact': 'admin@example.com', + 'comments': 'Another provider', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_provider(self): - - provider = Provider.objects.first() - response = self.client.get(provider.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_provider_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Provider 4,provider-4", "Provider 5,provider-5", "Provider 6,provider-6", ) - response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Provider.objects.count(), 6) + cls.bulk_edit_data = { + 'asn': 65009, + 'account': '5678', + 'portal_url': 'http://example.com/portal2', + 'noc_contact': 'noc2@example.com', + 'admin_contact': 'admin2@example.com', + 'comments': 'New comments', + } -class CircuitTypeTestCase(TestCase): +class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = CircuitType - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_circuittype', - 'circuits.add_circuittype', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): CircuitType.objects.bulk_create([ CircuitType(name='Circuit Type 1', slug='circuit-type-1'), @@ -74,79 +58,71 @@ class CircuitTypeTestCase(TestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) - def test_circuittype_list(self): + cls.form_data = { + 'name': 'Circuit Type X', + 'slug': 'circuit-type-x', + 'description': 'A new circuit type', + } - url = reverse('circuits:circuittype_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_circuittype_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Circuit Type 4,circuit-type-4", "Circuit Type 5,circuit-type-5", "Circuit Type 6,circuit-type-6", ) - response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(CircuitType.objects.count(), 6) +class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Circuit + @classmethod + def setUpTestData(cls): -class CircuitTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_circuit', - 'circuits.add_circuit', - ] + providers = ( + Provider(name='Provider 1', slug='provider-1', asn=65001), + Provider(name='Provider 2', slug='provider-2', asn=65002), ) - self.client = Client() - self.client.force_login(user) + Provider.objects.bulk_create(providers) - provider = Provider(name='Provider 1', slug='provider-1', asn=65001) - provider.save() - - circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1') - circuittype.save() + circuittypes = ( + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), + ) + CircuitType.objects.bulk_create(circuittypes) Circuit.objects.bulk_create([ - Circuit(cid='Circuit 1', provider=provider, type=circuittype), - Circuit(cid='Circuit 2', provider=provider, type=circuittype), - Circuit(cid='Circuit 3', provider=provider, type=circuittype), + Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]), + Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]), + Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), ]) - def test_circuit_list(self): - - url = reverse('circuits:circuit_list') - params = { - "provider": Provider.objects.first().slug, - "type": CircuitType.objects.first().slug, + cls.form_data = { + 'cid': 'Circuit X', + 'provider': providers[1].pk, + 'type': circuittypes[1].pk, + 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, + 'tenant': None, + 'install_date': datetime.date(2020, 1, 1), + 'commit_rate': 1000, + 'description': 'A new circuit', + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_circuit(self): - - circuit = Circuit.objects.first() - response = self.client.get(circuit.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_circuit_import(self): - - csv_data = ( + cls.csv_data = ( "cid,provider,type", "Circuit 4,Provider 1,Circuit Type 1", "Circuit 5,Provider 1,Circuit Type 1", "Circuit 6,Provider 1,Circuit Type 1", ) - response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)}) + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'type': circuittypes[1].pk, + 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, + 'tenant': None, + 'commit_rate': 2000, + 'description': 'New description', + 'comments': 'New comments', - self.assertEqual(response.status_code, 200) - self.assertEqual(Circuit.objects.count(), 6) + } diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index c142a831a..72d9720df 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -9,42 +9,42 @@ app_name = 'circuits' urlpatterns = [ # Providers - path(r'providers/', views.ProviderListView.as_view(), name='provider_list'), - path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), - path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), - path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), - path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path(r'providers//', views.ProviderView.as_view(), name='provider'), - path(r'providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path(r'providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path(r'providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers/', views.ProviderListView.as_view(), name='provider_list'), + path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), + path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), + path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), + path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types - path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), - path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), - path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), - path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path(r'circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path(r'circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), + path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits - path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'), - path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), - path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), - path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), - path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - path(r'circuits//', views.CircuitView.as_view(), name='circuit'), - path(r'circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), - path(r'circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), - path(r'circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path(r'circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), + path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), + path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), + path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), + path('circuits//', views.CircuitView.as_view(), name='circuit'), + path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), + path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), + path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations - path(r'circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), - path(r'circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), - path(r'circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path(r'circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), - path(r'circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + path('circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 15cf901c1..ba873f23f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderDetailTable - template_name = 'circuits/provider_list.html' class ProviderView(PermissionRequiredMixin, View): @@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): permission_required = 'circuits.view_circuittype' queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable - template_name = 'circuits/circuittype_list.html' class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): @@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable - template_name = 'circuits/circuit_list.html' class CircuitView(PermissionRequiredMixin, View): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f0382a3f5..234a9fb1c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) @@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) @@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = PowerPortTemplateSerializer( @@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) class Meta: @@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = NestedPowerPortSerializer( @@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) cable = NestedCableSerializer( read_only=True @@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) - length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fd55d9b05..5a915becc 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -15,65 +15,65 @@ router = routers.DefaultRouter() router.APIRootView = DCIMRootView # Field choices -router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') # Sites -router.register(r'regions', views.RegionViewSet) -router.register(r'sites', views.SiteViewSet) +router.register('regions', views.RegionViewSet) +router.register('sites', views.SiteViewSet) # Racks -router.register(r'rack-groups', views.RackGroupViewSet) -router.register(r'rack-roles', views.RackRoleViewSet) -router.register(r'racks', views.RackViewSet) -router.register(r'rack-reservations', views.RackReservationViewSet) +router.register('rack-groups', views.RackGroupViewSet) +router.register('rack-roles', views.RackRoleViewSet) +router.register('racks', views.RackViewSet) +router.register('rack-reservations', views.RackReservationViewSet) # Device types -router.register(r'manufacturers', views.ManufacturerViewSet) -router.register(r'device-types', views.DeviceTypeViewSet) +router.register('manufacturers', views.ManufacturerViewSet) +router.register('device-types', views.DeviceTypeViewSet) # Device type components -router.register(r'console-port-templates', views.ConsolePortTemplateViewSet) -router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet) -router.register(r'power-port-templates', views.PowerPortTemplateViewSet) -router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) -router.register(r'interface-templates', views.InterfaceTemplateViewSet) -router.register(r'front-port-templates', views.FrontPortTemplateViewSet) -router.register(r'rear-port-templates', views.RearPortTemplateViewSet) -router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('console-port-templates', views.ConsolePortTemplateViewSet) +router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet) +router.register('power-port-templates', views.PowerPortTemplateViewSet) +router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) +router.register('interface-templates', views.InterfaceTemplateViewSet) +router.register('front-port-templates', views.FrontPortTemplateViewSet) +router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('device-bay-templates', views.DeviceBayTemplateViewSet) # Devices -router.register(r'device-roles', views.DeviceRoleViewSet) -router.register(r'platforms', views.PlatformViewSet) -router.register(r'devices', views.DeviceViewSet) +router.register('device-roles', views.DeviceRoleViewSet) +router.register('platforms', views.PlatformViewSet) +router.register('devices', views.DeviceViewSet) # Device components -router.register(r'console-ports', views.ConsolePortViewSet) -router.register(r'console-server-ports', views.ConsoleServerPortViewSet) -router.register(r'power-ports', views.PowerPortViewSet) -router.register(r'power-outlets', views.PowerOutletViewSet) -router.register(r'interfaces', views.InterfaceViewSet) -router.register(r'front-ports', views.FrontPortViewSet) -router.register(r'rear-ports', views.RearPortViewSet) -router.register(r'device-bays', views.DeviceBayViewSet) -router.register(r'inventory-items', views.InventoryItemViewSet) +router.register('console-ports', views.ConsolePortViewSet) +router.register('console-server-ports', views.ConsoleServerPortViewSet) +router.register('power-ports', views.PowerPortViewSet) +router.register('power-outlets', views.PowerOutletViewSet) +router.register('interfaces', views.InterfaceViewSet) +router.register('front-ports', views.FrontPortViewSet) +router.register('rear-ports', views.RearPortViewSet) +router.register('device-bays', views.DeviceBayViewSet) +router.register('inventory-items', views.InventoryItemViewSet) # Connections -router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') -router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections') -router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') +router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') +router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections') +router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') # Cables -router.register(r'cables', views.CableViewSet) +router.register('cables', views.CableViewSet) # Virtual chassis -router.register(r'virtual-chassis', views.VirtualChassisViewSet) +router.register('virtual-chassis', views.VirtualChassisViewSet) # Power -router.register(r'power-panels', views.PowerPanelViewSet) -router.register(r'power-feeds', views.PowerFeedViewSet) +router.register('power-panels', views.PowerPanelViewSet) +router.register('power-feeds', views.PowerFeedViewSet) # Miscellaneous -router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') +router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device') app_name = 'dcim-api' urlpatterns = router.urls diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0e05867e4..13a5052e4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -9,6 +9,8 @@ from .choices import InterfaceTypeChoices RACK_U_HEIGHT_DEFAULT = 42 +RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 + RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ca5d25389..4c8a0821f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,7 +13,8 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, + LocalConfigContextFilterForm, ) from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN @@ -21,9 +22,9 @@ 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, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, - SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -40,7 +41,7 @@ DEVICE_BY_PK_RE = r'{\d+\}' INTERFACE_MODE_HELP_TEXT = """ Access: One untagged VLAN
Tagged: One untagged VLAN and/or one or more tagged VLANs
-Tagged All: Implies all VLANs are available (w/optional untagged VLAN) +Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ @@ -65,7 +66,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -77,12 +78,24 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", - value_field="slug" + value_field="slug", + filter_for={ + 'device_id': 'site', + } + ) + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', ) ) @@ -169,18 +182,18 @@ class MACAddressField(forms.Field): # class RegionForm(BootstrapMixin, forms.ModelForm): + parent = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2() + ) slug = SlugField() class Meta: model = Region - fields = [ + fields = ( 'parent', 'name', 'slug', - ] - widgets = { - 'parent': APISelect( - api_url="/api/dcim/regions/" - ) - } + ) class RegionCSVForm(forms.ModelForm): @@ -215,13 +228,11 @@ class RegionFilterForm(BootstrapMixin, forms.Form): # Sites # -class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) slug = SlugField() comments = CommentField() @@ -263,7 +274,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteCSVForm(forms.ModelForm): +class SiteCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, @@ -312,11 +323,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -357,7 +366,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -366,6 +375,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): value_field="slug", ) ) + tag = TagFilterField(model) # @@ -373,18 +383,20 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: model = RackGroup - fields = [ + fields = ( 'site', 'name', 'slug', - ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } + ) class RackGroupCSVForm(forms.ModelForm): @@ -407,7 +419,7 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -419,9 +431,10 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -459,17 +472,30 @@ class RackRoleCSVForm(forms.ModelForm): # Racks # -class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): - group = ChainedModelChoiceField( +class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id', + } + ) + ) + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', ) ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-roles/', + ) + ) comments = CommentField() tags = TagField( required=False @@ -488,23 +514,14 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'u_height': "Height in rack units", } widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'group': 'site_id', - } - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/dcim/rack-roles/" - ), 'type': StaticSelect2(), 'width': StaticSelect2(), 'outer_unit': StaticSelect2(), } -class RackCSVForm(forms.ModelForm): +class RackCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -597,7 +614,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -607,14 +624,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor } ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/rack-groups", ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -627,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False, widget=APISelect( @@ -693,7 +710,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -705,9 +722,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -716,12 +734,12 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related( 'site' ), + required=False, label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True @@ -732,16 +750,17 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", null_option=True, ) ) + tag = TagFilterField(model) # @@ -750,13 +769,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = ChainedModelChoiceField( + id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label='Rack', - chains=( - ('site', 'site'), - ('group_id', 'group_id'), - ), required=False, widget=APISelectMultiple( api_url='/api/dcim/racks/', @@ -827,7 +842,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -849,18 +864,19 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), + required=False, label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -897,7 +913,13 @@ class ManufacturerCSVForm(forms.ModelForm): # Device types # -class DeviceTypeForm(BootstrapMixin, CustomFieldForm): +class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( slug_source='model' ) @@ -913,9 +935,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): 'tags', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'subdevice_role': StaticSelect2() } @@ -938,11 +957,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput() ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/manufactureres" + api_url="/api/dcim/manufacturers" ) ) u_height = forms.IntegerField( @@ -965,9 +984,10 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -1020,6 +1040,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) # @@ -1038,16 +1059,37 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(ComponentForm): +class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) type = forms.ChoiceField( - choices=ConsolePortTypeChoices, + choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) +class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type',) + + class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1060,7 +1102,13 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(ComponentForm): +class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1070,6 +1118,21 @@ class ConsoleServerPortTemplateCreateForm(ComponentForm): ) +class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type',) + + class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1082,7 +1145,13 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(ComponentForm): +class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1102,6 +1171,31 @@ class PowerPortTemplateCreateForm(ComponentForm): ) +class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect2() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum power draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated power draw (watts)" + ) + + class Meta: + nullable_fields = ('type', 'maximum_draw', 'allocated_draw') + + class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1124,7 +1218,13 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletTemplateCreateForm(ComponentForm): +class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1143,13 +1243,35 @@ class PowerOutletTemplateCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit power_port choices to current DeviceType - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.parent + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') ) + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=device_type + ) + + +class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutletTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerOutletTypeChoices), + required=False, + widget=StaticSelect2() + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(PowerOutletFeedLegChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type', 'feed_leg') class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1165,7 +1287,13 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(ComponentForm): +class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1222,7 +1350,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): ) -class FrontPortTemplateCreateForm(ComponentForm): +class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1237,18 +1371,21 @@ class FrontPortTemplateCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. occupied_port_positions = [ (front_port.rear_port_id, front_port.rear_port_position) - for front_port in self.parent.frontport_templates.all() + for front_port in device_type.frontport_templates.all() ] # Populate rear port choices choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=self.parent) + rear_ports = RearPortTemplate.objects.filter(device_type=device_type) for rear_port in rear_ports: for i in range(1, rear_port.positions + 1): if (rear_port.pk, i) not in occupied_port_positions: @@ -1279,6 +1416,21 @@ class FrontPortTemplateCreateForm(ComponentForm): } +class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = () + + class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1292,7 +1444,13 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class RearPortTemplateCreateForm(ComponentForm): +class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1308,6 +1466,21 @@ class RearPortTemplateCreateForm(ComponentForm): ) +class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = () + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1320,12 +1493,29 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(ComponentForm): +class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) +# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet +# class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): +# pk = forms.ModelMultipleChoiceField( +# queryset=FrontPortTemplate.objects.all(), +# widget=forms.MultipleHiddenInput() +# ) +# +# class Meta: +# nullable_fields = () + + # # Component template import forms # @@ -1475,6 +1665,13 @@ class DeviceRoleCSVForm(forms.ModelForm): # class PlatformForm(BootstrapMixin, forms.ModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( max_length=64 ) @@ -1485,9 +1682,6 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'napalm_args': SmallTextarea(), } @@ -1516,8 +1710,8 @@ class PlatformCSVForm(forms.ModelForm): # Devices # -class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): - site = forms.ModelChoiceField( +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( api_url="/api/dcim/sites/", @@ -1526,11 +1720,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -1546,8 +1737,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): disabled_indicator='device' ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", filter_for={ @@ -1556,18 +1748,30 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - device_type = ChainedModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - chains=( - ('manufacturer', 'manufacturer'), - ), - label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', display_field='model' ) ) - cluster_group = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-roles/' + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/platforms/", + additional_query_params={ + "manufacturer_id": "null" + } + ) + ) + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -1580,11 +1784,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), required=False, widget=APISelect( api_url='/api/virtualization/clusters/', @@ -1616,16 +1817,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'position': 'face' } ), - 'device_role': APISelect( - api_url='/api/dcim/device-roles/' - ), 'status': StaticSelect2(), - 'platform': APISelect( - api_url="/api/dcim/platforms/", - additional_query_params={ - "manufacturer_id": "null" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), } @@ -1724,7 +1916,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceCSVForm(forms.ModelForm): +class BaseDeviceCSVForm(CustomFieldModelCSVForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -1899,31 +2091,29 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() ) - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Type', widget=APISelect( api_url="/api/dcim/device-types/", display_field='display_name' ) ) - device_role = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, - label='Role', widget=APISelect( api_url="/api/dcim/device-roles/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url="/api/tenancy/tenants/" ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -1933,7 +2123,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), required=False, - initial='', widget=StaticSelect2() ) serial = forms.CharField( @@ -1958,7 +2147,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1970,9 +2159,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1982,10 +2172,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.prefetch_related( - 'site' - ), + rack_group_id = DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -1994,25 +2183,27 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - manufacturer_id = FilterChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), + required=False, label='Manufacturer', widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", @@ -2021,20 +2212,19 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - device_type_id = FilterChoiceField( - queryset=DeviceType.objects.prefetch_related( - 'manufacturer' - ), + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, label='Model', widget=APISelectMultiple( api_url="/api/dcim/device-types/", display_field="model", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -2106,6 +2296,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) # @@ -2154,6 +2345,7 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): class ConsolePortFilterForm(DeviceComponentFilterForm): model = ConsolePort + tag = TagFilterField(model) class ConsolePortForm(BootstrapMixin, forms.ModelForm): @@ -2171,7 +2363,13 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(ComponentForm): +class ConsolePortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2189,6 +2387,27 @@ class ConsolePortCreateForm(ComponentForm): ) +class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class ConsolePortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2211,6 +2430,7 @@ class ConsolePortCSVForm(forms.ModelForm): class ConsoleServerPortFilterForm(DeviceComponentFilterForm): model = ConsoleServerPort + tag = TagFilterField(model) class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): @@ -2228,7 +2448,13 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(ComponentForm): +class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2303,6 +2529,7 @@ class ConsoleServerPortCSVForm(forms.ModelForm): class PowerPortFilterForm(DeviceComponentFilterForm): model = PowerPort + tag = TagFilterField(model) class PowerPortForm(BootstrapMixin, forms.ModelForm): @@ -2320,7 +2547,13 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(ComponentForm): +class PowerPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2348,6 +2581,37 @@ class PowerPortCreateForm(ComponentForm): ) +class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect2() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum draw in watts" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated draw in watts" + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class PowerPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2370,6 +2634,7 @@ class PowerPortCSVForm(forms.ModelForm): class PowerOutletFilterForm(DeviceComponentFilterForm): model = PowerOutlet + tag = TagFilterField(model) class PowerOutletForm(BootstrapMixin, forms.ModelForm): @@ -2400,7 +2665,13 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletCreateForm(ComponentForm): +class PowerOutletCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2426,11 +2697,13 @@ class PowerOutletCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit power_port choices to those on the parent device - self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) + # Limit power_port queryset to PowerPorts which belong to the parent Device + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) class PowerOutletCSVForm(forms.ModelForm): @@ -2488,8 +2761,13 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput() ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=forms.HiddenInput() + ) type = forms.ChoiceField( - choices=PowerOutletTypeChoices, + choices=add_blank_choice(PowerOutletTypeChoices), required=False ) feed_leg = forms.ChoiceField( @@ -2514,7 +2792,12 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): super().__init__(*args, **kwargs) # Limit power_port queryset to PowerPorts which belong to the parent Device - self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent_obj) + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True class PowerOutletBulkRenameForm(BulkRenameForm): @@ -2538,28 +2821,36 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface + tag = TagFilterField(model) class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + label='Untagged VLAN', widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, + label='Tagged VLANs', widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) - tags = TagField( required=False ) @@ -2586,22 +2877,29 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or VC master) if self.is_bound: device = Device.objects.get(pk=self.data['device']) - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) else: device = self.instance.device - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.instance.device, self.instance.device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) + + # Limit LAG choices to interfaces belonging to this device (or VC master) + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + type=InterfaceTypeChoices.TYPE_LAG + ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) -class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): +class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2610,7 +2908,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): widget=StaticSelect2(), ) enabled = forms.BooleanField( - required=False + required=False, + initial=True ) lag = forms.ModelChoiceField( queryset=Interface.objects.all(), @@ -2645,41 +2944,46 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): tags = TagField( required=False ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) def __init__(self, *args, **kwargs): - - # Set interfaces enabled by default - kwargs['initial'] = kwargs.get('initial', {}).copy() - kwargs['initial'].update({'enabled': True}) - super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or its VC master) - if self.parent is not None: - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.parent, self.parent.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) - else: - self.fields['lag'].queryset = Interface.objects.none() + # Limit LAG choices to interfaces which belong to the parent device (or VC master) + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + type=InterfaceTypeChoices.TYPE_LAG + ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) class InterfaceCSVForm(forms.ModelForm): @@ -2726,7 +3030,7 @@ class InterfaceCSVForm(forms.ModelForm): super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -2754,6 +3058,11 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=forms.HiddenInput() + ) type = forms.ChoiceField( choices=add_blank_choice(InterfaceTypeChoices), required=False, @@ -2793,22 +3102,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -2821,14 +3136,19 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): super().__init__(*args, **kwargs) # Limit LAG choices to interfaces which belong to the parent device (or VC master) - device = self.parent_obj - if device is not None: + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) else: - self.fields['lag'].choices = [] + self.fields['lag'].choices = () + self.fields['lag'].widget.attrs['disabled'] = True def clean(self): @@ -2863,6 +3183,7 @@ class InterfaceBulkDisconnectForm(ConfirmationForm): class FrontPortFilterForm(DeviceComponentFilterForm): model = FrontPort + tag = TagFilterField(model) class FrontPortForm(BootstrapMixin, forms.ModelForm): @@ -2892,7 +3213,13 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic -class FrontPortCreateForm(ComponentForm): +class FrontPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2912,15 +3239,20 @@ class FrontPortCreateForm(ComponentForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. occupied_port_positions = [ (front_port.rear_port_id, front_port.rear_port_position) - for front_port in self.parent.frontports.all() + for front_port in device.frontports.all() ] # Populate rear port choices choices = [] - rear_ports = RearPort.objects.filter(device=self.parent) + rear_ports = RearPort.objects.filter(device=device) for rear_port in rear_ports: for i in range(1, rear_port.positions + 1): if (rear_port.pk, i) not in occupied_port_positions: @@ -3040,6 +3372,7 @@ class FrontPortBulkDisconnectForm(ConfirmationForm): class RearPortFilterForm(DeviceComponentFilterForm): model = RearPort + tag = TagFilterField(model) class RearPortForm(BootstrapMixin, forms.ModelForm): @@ -3058,7 +3391,13 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): } -class RearPortCreateForm(ComponentForm): +class RearPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -3134,11 +3473,11 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3150,11 +3489,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_rack = ChainedModelChoiceField( + termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'termination_b_site'), - ), label='Rack', required=False, widget=APISelect( @@ -3167,12 +3503,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_device = ChainedModelChoiceField( + termination_b_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack', 'termination_b_rack'), - ), label='Device', required=False, widget=APISelect( @@ -3270,8 +3602,8 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - termination_b_provider = forms.ModelChoiceField( +class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): + termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', required=False, @@ -3282,7 +3614,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3293,11 +3625,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_circuit = ChainedModelChoiceField( + termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), - chains=( - ('provider', 'termination_b_provider'), - ), label='Circuit', widget=APISelect( api_url='/api/circuits/circuits/', @@ -3312,7 +3641,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f widget=APISelect( api_url='/api/circuits/circuit-terminations/', disabled_indicator='cable', - display_field='term_side' + display_field='term_side', + full=True ) ) @@ -3324,8 +3654,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f ] -class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - termination_b_site = forms.ModelChoiceField( +class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3338,12 +3668,9 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_rackgroup = ChainedModelChoiceField( + termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), label='Rack Group', - chains=( - ('site', 'termination_b_site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -3353,12 +3680,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_powerpanel = ChainedModelChoiceField( + termination_b_powerpanel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack_group', 'termination_b_rackgroup'), - ), label='Power Panel', required=False, widget=APISelect( @@ -3582,20 +3905,23 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'rack_id': 'site', + 'device_id': 'site', } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field='slug', @@ -3604,13 +3930,16 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, + filter_for={ + 'device_id': 'rack_id', + } ) ) type = forms.MultipleChoiceField( @@ -3628,7 +3957,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3644,6 +3973,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay + tag = TagFilterField(model) class DeviceBayForm(BootstrapMixin, forms.ModelForm): @@ -3661,7 +3991,13 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(ComponentForm): +class DeviceBayCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -3753,15 +4089,19 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3772,15 +4112,19 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3791,15 +4135,19 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3814,6 +4162,19 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/" + ) + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) tags = TagField( required=False ) @@ -3823,14 +4184,42 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] - widgets = { - 'device': APISelect( - api_url="/api/dcim/devices/" - ), - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ) - } + + +class InventoryItemCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) + name_pattern = ExpandableNameField( + label='Name' + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + serial = forms.CharField( + max_length=50, + required=False, + ) + asset_tag = forms.CharField( + max_length=50, + required=False, + ) + description = forms.CharField( + max_length=100, + required=False + ) class InventoryItemCSVForm(forms.ModelForm): @@ -3862,14 +4251,14 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/devices/" ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -3898,7 +4287,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -3910,9 +4299,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -3921,7 +4311,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3929,9 +4319,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): api_url='/api/dcim/devices/', ) ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -3943,6 +4334,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) # @@ -4025,10 +4417,9 @@ class DeviceVCMembershipForm(forms.ModelForm): return vc_position -class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( +class VCMemberSelectForm(BootstrapMixin, forms.Form): + site = DynamicModelChoiceField( queryset=Site.objects.all(), - label='Site', required=False, widget=APISelect( api_url="/api/dcim/sites/", @@ -4038,12 +4429,8 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -4055,15 +4442,10 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - device = ChainedModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.filter( virtual_chassis__isnull=True ), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', widget=APISelect( api_url='/api/dcim/devices/', display_field='display_name', @@ -4086,7 +4468,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4098,18 +4480,19 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4119,16 +4502,17 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", null_option=True, ) ) + tag = TagFilterField(model) # @@ -4136,11 +4520,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): - rack_group = ChainedModelChoiceField( + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ) + ) + rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -4152,14 +4543,6 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'rack_group', 'name', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'rack_group': 'site_id', - } - ), - } class PowerPanelCSVForm(forms.ModelForm): @@ -4203,7 +4586,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4215,9 +4598,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4226,10 +4610,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - rack_group_id = FilterChoiceField( + rack_group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), + required=False, label='Rack group (ID)', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -4241,8 +4625,8 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # Power feeds # -class PowerFeedForm(BootstrapMixin, CustomFieldForm): - site = ChainedModelChoiceField( +class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -4253,6 +4637,19 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm): } ) ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + widget=APISelect( + api_url="/api/dcim/power-panels/" + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -4265,12 +4662,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm): 'max_utilization', 'comments', 'tags', ] widgets = { - 'power_panel': APISelect( - api_url="/api/dcim/power-panels/" - ), - 'rack': APISelect( - api_url="/api/dcim/racks/" - ), 'status': StaticSelect2(), 'type': StaticSelect2(), 'supply': StaticSelect2(), @@ -4286,7 +4677,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm): self.initial['site'] = self.instance.power_panel.site -class PowerFeedCSVForm(forms.ModelForm): +class PowerFeedCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -4369,7 +4760,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - powerpanel = forms.ModelChoiceField( + power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( @@ -4379,7 +4770,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } ) ) - rack = forms.ModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, widget=APISelect( @@ -4436,7 +4827,7 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4448,9 +4839,10 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4460,19 +4852,19 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - power_panel_id = FilterChoiceField( + power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), + required=False, label='Power panel', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, @@ -4507,3 +4899,4 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): max_utilization = forms.IntegerField( required=False ) + tag = TagFilterField(model) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index e1124b84e..502719646 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -1,18 +1,7 @@ from django.db.models import Manager, QuerySet -from django.db.models.expressions import RawSQL from .constants import NONCONNECTABLE_IFACE_TYPES -# Regular expressions for parsing Interface names -TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')" -SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)" -SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)" -POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)" -SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)" -ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)" -CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" -VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" - class InterfaceQuerySet(QuerySet): @@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet): class InterfaceManager(Manager): def get_queryset(self): - """ - Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field - is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel, - and virtual circuit: - - {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are coalesced to zero or null. For example, an interface named - GigabitEthernet1/2/3 would be parsed as follows: - - type = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = None - id = None - channel = 0 - vc = 0 - - The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not - match any of the prescribed fields. - - The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device - components. - """ - - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = [ - '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk' - - ] - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering) + return InterfaceQuerySet(self.model, using=self._db) diff --git a/netbox/dcim/migrations/0079_3569_rack_fields.py b/netbox/dcim/migrations/0079_3569_rack_fields.py index 4e76a270f..da544bb7a 100644 --- a/netbox/dcim/migrations/0079_3569_rack_fields.py +++ b/netbox/dcim/migrations/0079_3569_rack_fields.py @@ -37,7 +37,7 @@ def rack_status_to_slug(apps, schema_editor): def rack_outer_unit_to_slug(apps, schema_editor): Rack = apps.get_model('dcim', 'Rack') for id, slug in RACK_DIMENSION_CHOICES: - Rack.objects.filter(status=str(id)).update(status=slug) + Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug) class Migration(migrations.Migration): diff --git a/netbox/dcim/migrations/0092_fix_rack_outer_unit.py b/netbox/dcim/migrations/0092_fix_rack_outer_unit.py new file mode 100644 index 000000000..2a8cbf4e5 --- /dev/null +++ b/netbox/dcim/migrations/0092_fix_rack_outer_unit.py @@ -0,0 +1,27 @@ +from django.db import migrations + +RACK_DIMENSION_CHOICES = ( + (1000, 'mm'), + (2000, 'in'), +) + + +def rack_outer_unit_to_slug(apps, schema_editor): + Rack = apps.get_model('dcim', 'Rack') + for id, slug in RACK_DIMENSION_CHOICES: + Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0091_interface_type_other'), + ] + + operations = [ + # Fixes a missed field migration from #3569; see bug #4056. The original migration has also been fixed, + # so this can be omitted when squashing in the future. + migrations.RunPython( + code=rack_outer_unit_to_slug + ), + ] diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py new file mode 100644 index 000000000..4e3c941a1 --- /dev/null +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -0,0 +1,147 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) + + +def naturalize_consoleports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePort')) + + +def naturalize_consoleserverports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPort')) + + +def naturalize_powerports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPort')) + + +def naturalize_poweroutlets(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerOutlet')) + + +def naturalize_frontports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPort')) + + +def naturalize_rearports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPort')) + + +def naturalize_devicebays(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBay')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0092_fix_rack_outer_unit'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebay', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='frontport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='inventoryitem', + options={'ordering': ('device__id', 'parent__id', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlet', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='powerport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='rearport', + options={'ordering': ('device', '_name')}, + ), + migrations.AddField( + model_name='consoleport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebay', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='inventoryitem', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlet', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_consoleserverports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_powerports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_poweroutlets, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_frontports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_rearports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devicebays, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py new file mode 100644 index 000000000..24fe98e94 --- /dev/null +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -0,0 +1,138 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) + + +def naturalize_consoleporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePortTemplate')) + + +def naturalize_consoleserverporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate')) + + +def naturalize_powerporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPortTemplate')) + + +def naturalize_poweroutlettemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerOutletTemplate')) + + +def naturalize_frontporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPortTemplate')) + + +def naturalize_rearporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPortTemplate')) + + +def naturalize_devicebaytemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBayTemplate')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0093_device_component_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebaytemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='consoleporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_consoleserverporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_powerporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_poweroutlettemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_frontporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_rearporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devicebaytemplates, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py new file mode 100644 index 000000000..3bc780161 --- /dev/null +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -0,0 +1,70 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) + + +def naturalize_sites(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Site')) + + +def naturalize_racks(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Rack')) + + +def naturalize_devices(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Device')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0094_device_component_template_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'group', '_name', 'pk')}, + ), + migrations.AlterModelOptions( + name='site', + options={'ordering': ('_name',)}, + ), + migrations.AddField( + model_name='device', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), + ), + migrations.AddField( + model_name='rack', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='site', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_sites, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_racks, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devices, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py new file mode 100644 index 000000000..f1622f504 --- /dev/null +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -0,0 +1,53 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name, max_length=100)) + + +def naturalize_interfacetemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'InterfaceTemplate')) + + +def naturalize_interfaces(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Interface')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0095_primary_model_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='interface', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='interface', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.AddField( + model_name='interfacetemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.RunPython( + code=naturalize_interfacetemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_interfaces, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 350330757..29afef1f1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -22,8 +22,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem -from utilities.fields import ColorField -from utilities.managers import NaturalOrderingManager +from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import foreground_color, to_meters from .device_component_templates import ( @@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel): max_length=50, unique=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) slug = models.SlugField( unique=True ) @@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): } class Meta: - ordering = ['name'] + ordering = ('_name',) def __str__(self): return self.name @@ -380,13 +382,17 @@ class RackElevationHelperMixin: # add gradients RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff') - RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0') - RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7') + RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing @staticmethod def _draw_device_front(drawing, device, start, end, text): + name = str(device) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + color = device.device_role.color link = drawing.add( drawing.a( @@ -401,7 +407,7 @@ class RackElevationHelperMixin: )) link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(device), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text, fill=hex_color)) @staticmethod def _draw_device_rear(drawing, device, start, end, text): @@ -431,11 +437,19 @@ class RackElevationHelperMixin: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): + def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width): - drawing = self._setup_drawing(unit_width, unit_height * self.u_height) + drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height) unit_cursor = 0 + for ru in range(0, self.u_height): + start_y = ru * unit_height + position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2) + unit = ru + 1 if self.desc_units else self.u_height - ru + drawing.add( + drawing.text(str(unit), position_coordinates, class_="unit") + ) + for unit in elevation: # Loop through all units in the elevation @@ -445,9 +459,9 @@ class RackElevationHelperMixin: # Setup drawing coordinates start_y = unit_cursor * unit_height end_y = unit_height * height - start_cordinates = (0, start_y) - end_cordinates = (unit_width, end_y) - text_cordinates = (unit_width / 2, start_y + end_y / 2) + start_cordinates = (legend_width, start_y) + end_cordinates = (legend_width + unit_width, end_y) + text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2) # Draw the device if device and device.face == face: @@ -469,7 +483,7 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing @@ -492,7 +506,8 @@ class RackElevationHelperMixin: self, face=DeviceFaceChoices.FACE_FRONT, unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT ): """ Return an SVG of the rack elevation @@ -505,7 +520,7 @@ class RackElevationHelperMixin: elevation = self.merge_elevations(face) reserved_units = self.get_reserved_units() - return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width) class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): @@ -516,6 +531,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) facility_id = models.CharField( max_length=50, blank=True, @@ -612,8 +632,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -634,12 +652,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): } class Meta: - ordering = ('site', 'group', 'name', 'pk') # (site, group, name) may be non-unique - unique_together = [ + ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique + unique_together = ( # Name and facility_id must be unique *only* within a RackGroup - ['group', 'name'], - ['group', 'facility_id'], - ] + ('group', 'name'), + ('group', 'facility_id'), + ) def __str__(self): return self.display_name or super().__str__() @@ -1018,9 +1036,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) - csv_headers = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', - ] clone_fields = [ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', ] @@ -1316,6 +1331,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): blank=True, null=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True, + null=True + ) serial = models.CharField( max_length=50, blank=True, @@ -1410,8 +1431,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -1433,12 +1452,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): } class Meta: - ordering = ('name', 'pk') # Name may be NULL - unique_together = [ - ['site', 'tenant', 'name'], # See validate_unique below - ['rack', 'position', 'face'], - ['virtual_chassis', 'vc_position'], - ] + ordering = ('_name', 'pk') # Name may be null + unique_together = ( + ('site', 'tenant', 'name'), # See validate_unique below + ('rack', 'position', 'face'), + ('virtual_chassis', 'vc_position'), + ) permissions = ( ('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'), diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 2aa46d0ea..faa42b035 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,9 +4,9 @@ from django.db import models from dcim.choices import * from dcim.constants import * -from dcim.managers import InterfaceManager from extras.models import ObjectChange -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, @@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel): help_text="Allocated power draw (watts)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -159,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel): return PowerPort( device=device, name=self.name, + type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw ) @@ -176,6 +186,11 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -195,11 +210,9 @@ class PowerOutletTemplate(ComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -220,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel): return PowerOutlet( device=device, name=self.name, + type=self.type, power_port=power_port, feed_leg=self.feed_leg ) @@ -237,6 +251,12 @@ class InterfaceTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=InterfaceTypeChoices @@ -246,11 +266,9 @@ class InterfaceTemplate(ComponentTemplateModel): verbose_name='Management only' ) - objects = InterfaceManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -276,6 +294,11 @@ class FrontPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -290,14 +313,12 @@ class FrontPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = [ - ['device_type', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -344,6 +365,11 @@ class RearPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -353,11 +379,9 @@ class RearPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -383,12 +407,15 @@ class DeviceBayTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) - - objects = NaturalOrderingManager() + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 68bab8037..a41eda576 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,9 +10,9 @@ from dcim.choices import * from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField -from dcim.managers import InterfaceManager from extras.models import ObjectChange, TaggedItem -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -515,6 +529,12 @@ class Interface(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) _connected_interface = models.OneToOneField( to='self', on_delete=models.SET_NULL, @@ -583,8 +603,6 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) - - objects = InterfaceManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -593,8 +611,9 @@ class Interface(CableTermination, ComponentModel): ] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + # TODO: ordering and unique_together should include virtual_machine + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -676,7 +695,7 @@ class Interface(CableTermination, ComponentModel): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED: + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: self.tagged_vlans.clear() return super().save(*args, **kwargs) @@ -761,6 +780,11 @@ class FrontPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -774,20 +798,17 @@ class FrontPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = [ - ['device', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device', '_name') + unique_together = ( + ('device', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -831,6 +852,11 @@ class RearPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -839,17 +865,14 @@ class RearPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -881,6 +904,11 @@ class DeviceBay(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) installed_device = models.OneToOneField( to='dcim.Device', on_delete=models.SET_NULL, @@ -888,15 +916,13 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'installed_device', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return '{} - {}'.format(self.device.name, self.name) @@ -960,6 +986,11 @@ class InventoryItem(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -997,14 +1028,14 @@ class InventoryItem(ComponentModel): ] class Meta: - ordering = ['device__id', 'parent__id', 'name'] - unique_together = ['device', 'parent', 'name'] + ordering = ('device__id', 'parent__id', '_name') + unique_together = ('device', 'parent', 'name') def __str__(self): return self.name def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk}) def to_csv(self): return ( diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 7e1da41d4..1f67b93f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -200,6 +200,11 @@ def get_component_template_actions(model_name): {{% endif %}} + {{% if perms.dcim.delete_{model_name} %}} + + + + {{% endif %}} """.format(model_name=model_name).strip() @@ -229,7 +234,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn(order_by=('_name',)) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -291,7 +296,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn(order_by=('_name',)) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -409,6 +414,7 @@ class DeviceTypeTable(BaseTable): class ConsolePortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -432,6 +438,7 @@ class ConsolePortImportTable(BaseTable): class ConsoleServerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleserverporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -440,7 +447,7 @@ class ConsoleServerPortTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = ConsoleServerPortTemplate - fields = ('pk', 'name', 'actions') + fields = ('pk', 'name', 'type', 'actions') empty_text = "None" @@ -455,6 +462,7 @@ class ConsoleServerPortImportTable(BaseTable): class PowerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('powerporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -478,6 +486,7 @@ class PowerPortImportTable(BaseTable): class PowerOutletTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('poweroutlettemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -526,6 +535,7 @@ class InterfaceImportTable(BaseTable): class FrontPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) rear_port_position = tables.Column( verbose_name='Position' ) @@ -552,6 +562,7 @@ class FrontPortImportTable(BaseTable): class RearPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('rearporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -575,6 +586,7 @@ class RearPortImportTable(BaseTable): class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('devicebaytemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -654,7 +666,7 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() name = tables.TemplateColumn( - order_by=('_nat1', '_nat2', '_nat3'), + order_by=('_name',), template_code=DEVICE_LINK ) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') @@ -704,6 +716,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() class Meta(BaseTable.Meta): @@ -713,6 +726,7 @@ class DeviceComponentDetailTable(BaseTable): class ConsolePortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsolePort @@ -727,6 +741,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable): class ConsoleServerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsoleServerPort @@ -741,6 +756,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable): class PowerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerPort @@ -755,6 +771,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable): class PowerOutletTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerOutlet @@ -777,6 +794,7 @@ class InterfaceTable(BaseTable): class InterfaceDetailTable(DeviceComponentDetailTable): parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) + name = tables.LinkColumn() class Meta(InterfaceTable.Meta): order_by = ('parent', 'name') @@ -785,6 +803,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable): class FrontPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = FrontPort @@ -800,6 +819,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable): class RearPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = RearPort @@ -815,6 +835,7 @@ class RearPortDetailTable(DeviceComponentDetailTable): class DeviceBayTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = DeviceBay diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 856862a3e..704dedb40 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,116 +1,123 @@ -import urllib.parse +from decimal import Decimal +import pytz import yaml -from django.test import Client, TestCase +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from utilities.testing import create_test_user +from ipam.models import VLAN +from utilities.testing import ViewTestCases -class RegionTestCase(TestCase): +def create_test_device(name): + """ + Convenience method for creating a Device (e.g. for component testing). + """ + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') + devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) + devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_region', - 'dcim.add_region', - ] - ) - self.client = Client() - self.client.force_login(user) + return device + + +class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Region + + @classmethod + def setUpTestData(cls): # Create three Regions - for i in range(1, 4): - Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save() + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() - def test_region_list(self): + cls.form_data = { + 'name': 'Region X', + 'slug': 'region-x', + 'parent': regions[2].pk, + } - url = reverse('dcim:region_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_region_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Region 4,region-4", "Region 5,region-5", "Region 6,region-6", ) - response = self.client.post(reverse('dcim:region_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(Region.objects.count(), 6) +class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Site + @classmethod + def setUpTestData(cls): -class SiteTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_site', - 'dcim.add_site', - ] + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), ) - self.client = Client() - self.client.force_login(user) - - region = Region(name='Region 1', slug='region-1') - region.save() + for region in regions: + region.save() Site.objects.bulk_create([ - Site(name='Site 1', slug='site-1', region=region), - Site(name='Site 2', slug='site-2', region=region), - Site(name='Site 3', slug='site-3', region=region), + Site(name='Site 1', slug='site-1', region=regions[0]), + Site(name='Site 2', slug='site-2', region=regions[0]), + Site(name='Site 3', slug='site-3', region=regions[0]), ]) - def test_site_list(self): - - url = reverse('dcim:site_list') - params = { - "region": Region.objects.first().slug, + cls.form_data = { + 'name': 'Site X', + 'slug': 'site-x', + 'status': SiteStatusChoices.STATUS_PLANNED, + 'region': regions[1].pk, + 'tenant': None, + 'facility': 'Facility X', + 'asn': 65001, + 'time_zone': pytz.UTC, + 'description': 'Site description', + 'physical_address': '742 Evergreen Terrace, Springfield, USA', + 'shipping_address': '742 Evergreen Terrace, Springfield, USA', + 'latitude': Decimal('35.780000'), + 'longitude': Decimal('-78.642000'), + 'contact_name': 'Hank Hill', + 'contact_phone': '123-555-9999', + 'contact_email': 'hank@stricklandpropane.com', + 'comments': 'Test site', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_site(self): - - site = Site.objects.first() - response = self.client.get(site.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_site_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Site 4,site-4", "Site 5,site-5", "Site 6,site-6", ) - response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Site.objects.count(), 6) + cls.bulk_edit_data = { + 'status': SiteStatusChoices.STATUS_PLANNED, + 'region': regions[1].pk, + 'tenant': None, + 'asn': 65009, + 'time_zone': pytz.timezone('US/Eastern'), + 'description': 'New description', + } -class RackGroupTestCase(TestCase): +class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = RackGroup - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rackgroup', - 'dcim.add_rackgroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -121,39 +128,25 @@ class RackGroupTestCase(TestCase): RackGroup(name='Rack Group 3', slug='rack-group-3', site=site), ]) - def test_rackgroup_list(self): + cls.form_data = { + 'name': 'Rack Group X', + 'slug': 'rack-group-x', + 'site': site.pk, + } - url = reverse('dcim:rackgroup_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_rackgroup_import(self): - - csv_data = ( + cls.csv_data = ( "site,name,slug", "Site 1,Rack Group 4,rack-group-4", "Site 1,Rack Group 5,rack-group-5", "Site 1,Rack Group 6,rack-group-6", ) - response = self.client.post(reverse('dcim:rackgroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(RackGroup.objects.count(), 6) +class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = RackRole - -class RackRoleTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rackrole', - 'dcim.add_rackrole', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): RackRole.objects.bulk_create([ RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -161,118 +154,144 @@ class RackRoleTestCase(TestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) - def test_rackrole_list(self): + cls.form_data = { + 'name': 'Rack Role X', + 'slug': 'rack-role-x', + 'color': 'c0c0c0', + 'description': 'New role', + } - url = reverse('dcim:rackrole_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_rackrole_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug,color", "Rack Role 4,rack-role-4,ff0000", "Rack Role 5,rack-role-5,00ff00", "Rack Role 6,rack-role-6,0000ff", ) - response = self.client.post(reverse('dcim:rackrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(RackRole.objects.count(), 6) +class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = RackReservation + # Disable inapplicable tests + test_get_object = None + test_create_object = None -class RackReservationTestCase(TestCase): + # TODO: Fix URL name for view + test_import_objects = None - def setUp(self): - user = create_test_user(permissions=['dcim.view_rackreservation']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + user2 = User.objects.create_user(username='testuser2') + user3 = User.objects.create_user(username='testuser3') + + site = Site.objects.create(name='Site 1', slug='site-1') rack = Rack(name='Rack 1', site=site) rack.save() RackReservation.objects.bulk_create([ - RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'), - RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'), - RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'), + RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'), + RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'), + RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), ]) - def test_rackreservation_list(self): - - url = reverse('dcim:rackreservation_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - -class RackTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rack', - 'dcim.add_rack', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - Rack.objects.bulk_create([ - Rack(name='Rack 1', site=site), - Rack(name='Rack 2', site=site), - Rack(name='Rack 3', site=site), - ]) - - def test_rack_list(self): - - url = reverse('dcim:rack_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'rack': rack.pk, + 'units': [10, 11, 12], + 'user': user3.pk, + 'tenant': None, + 'description': 'Rack reservation', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'user': user3.pk, + 'tenant': None, + 'description': 'New description', + } - def test_rack(self): - rack = Rack.objects.first() - response = self.client.get(rack.get_absolute_url()) - self.assertEqual(response.status_code, 200) +class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Rack - def test_rack_import(self): + @classmethod + def setUpTestData(cls): - csv_data = ( + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + rackgroups = ( + RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]) + ) + RackGroup.objects.bulk_create(rackgroups) + + rackroles = ( + RackRole(name='Rack Role 1', slug='rack-role-1'), + RackRole(name='Rack Role 2', slug='rack-role-2'), + ) + RackRole.objects.bulk_create(rackroles) + + Rack.objects.bulk_create(( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[0]), + Rack(name='Rack 3', site=sites[0]), + )) + + cls.form_data = { + 'name': 'Rack X', + 'facility_id': 'Facility X', + 'site': sites[1].pk, + 'group': rackgroups[1].pk, + 'tenant': None, + 'status': RackStatusChoices.STATUS_PLANNED, + 'role': rackroles[1].pk, + 'serial': '123456', + 'asset_tag': 'ABCDEF', + 'type': RackTypeChoices.TYPE_CABINET, + 'width': RackWidthChoices.WIDTH_19IN, + 'u_height': 48, + 'desc_units': False, + 'outer_width': 500, + 'outer_depth': 500, + 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.csv_data = ( "site,name,width,u_height", "Site 1,Rack 4,19,42", "Site 1,Rack 5,19,42", "Site 1,Rack 6,19,42", ) - response = self.client.post(reverse('dcim:rack_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Rack.objects.count(), 6) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'group': rackgroups[1].pk, + 'tenant': None, + 'status': RackStatusChoices.STATUS_DEPRECATED, + 'role': rackroles[1].pk, + 'serial': '654321', + 'type': RackTypeChoices.TYPE_4POST, + 'width': RackWidthChoices.WIDTH_23IN, + 'u_height': 49, + 'desc_units': True, + 'outer_width': 30, + 'outer_depth': 30, + 'outer_unit': RackDimensionUnitChoices.UNIT_INCH, + 'comments': 'New comments', + } -class ManufacturerTypeTestCase(TestCase): +class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Manufacturer - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_manufacturer', - 'dcim.add_manufacturer', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Manufacturer.objects.bulk_create([ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -280,73 +299,59 @@ class ManufacturerTypeTestCase(TestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) - def test_manufacturer_list(self): + cls.form_data = { + 'name': 'Manufacturer X', + 'slug': 'manufacturer-x', + } - url = reverse('dcim:manufacturer_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_manufacturer_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Manufacturer 4,manufacturer-4", "Manufacturer 5,manufacturer-5", "Manufacturer 6,manufacturer-6", ) - response = self.client.post(reverse('dcim:manufacturer_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(Manufacturer.objects.count(), 6) +class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = DeviceType + @classmethod + def setUpTestData(cls): -class DeviceTypeTestCase(TestCase): - - def setUp(self): - user = create_test_user(permissions=['dcim.view_devicetype']) - self.client = Client() - self.client.force_login(user) - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2') + ) + Manufacturer.objects.bulk_create(manufacturers) DeviceType.objects.bulk_create([ - DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer), - DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer), - DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer), + DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), + DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]), + DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]), ]) - def test_devicetype_list(self): - - url = reverse('dcim:devicetype_list') - params = { - "manufacturer": Manufacturer.objects.first().slug, + cls.form_data = { + 'manufacturer': manufacturers[1].pk, + 'model': 'Device Type X', + 'slug': 'device-type-x', + 'part_number': '123ABC', + 'u_height': 2, + 'is_full_depth': True, + 'subdevice_role': '', # CharField + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_devicetype_export(self): - - url = reverse('dcim:devicetype_list') - - response = self.client.get('{}?export'.format(url)) - self.assertEqual(response.status_code, 200) - data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) - self.assertEqual(len(data), 3) - self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') - self.assertEqual(data[0]['model'], 'Device Type 1') - - def test_devicetype(self): - - devicetype = DeviceType.objects.first() - response = self.client.get(devicetype.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_devicetype_import(self): + cls.bulk_edit_data = { + 'manufacturer': manufacturers[1].pk, + 'u_height': 3, + 'is_full_depth': False, + } + def test_import_objects(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ IMPORT_DATA = """ manufacturer: Generic model: TEST-1000 @@ -420,8 +425,8 @@ device-bays: # Create the manufacturer Manufacturer(name='Generic', slug='generic').save() - # Authenticate as user with necessary permissions - user = create_test_user(username='testuser2', permissions=[ + # Add all required permissions to the test user + self.add_permissions( 'dcim.view_devicetype', 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', @@ -432,15 +437,14 @@ device-bays: 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', 'dcim.add_devicebaytemplate', - ]) - self.client.force_login(user) + ) form_data = { 'data': IMPORT_DATA, 'format': 'yaml' } response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) dt = DeviceType.objects.get(model='TEST-1000') @@ -487,18 +491,333 @@ device-bays: db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') + def test_devicetype_export(self): -class DeviceRoleTestCase(TestCase): + url = reverse('dcim:devicetype_list') + self.add_permissions('dcim.view_devicetype') - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_devicerole', - 'dcim.add_devicerole', - ] + response = self.client.get('{}?export'.format(url)) + self.assertEqual(response.status_code, 200) + data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) + self.assertEqual(len(data), 3) + self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') + self.assertEqual(data[0]['model'], 'Device Type 1') + + +# +# DeviceType components +# + +class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ConsolePortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), ) - self.client = Client() - self.client.force_login(user) + DeviceType.objects.bulk_create(devicetypes) + + ConsolePortTemplate.objects.bulk_create(( + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'), + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'), + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Console Port Template X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Console Port Template [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + +class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ConsoleServerPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + ConsoleServerPortTemplate.objects.bulk_create(( + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'), + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'), + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Console Server Port Template X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Console Server Port Template [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + +class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = PowerPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + PowerPortTemplate.objects.bulk_create(( + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'), + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Power Port Template X', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Power Port Template [4-6]', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + cls.bulk_edit_data = { + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + +class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = PowerOutletTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + + PowerOutletTemplate.objects.bulk_create(( + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'), + )) + + powerports = ( + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + ) + PowerPortTemplate.objects.bulk_create(powerports) + + cls.form_data = { + 'device_type': devicetype.pk, + 'name': 'Power Outlet Template X', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[0].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + cls.bulk_create_data = { + 'device_type': devicetype.pk, + 'name_pattern': 'Power Outlet Template [4-6]', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[0].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + cls.bulk_edit_data = { + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + +class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = InterfaceTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + InterfaceTemplate.objects.bulk_create(( + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'), + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'), + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Interface Template X', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Interface Template [4-6]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + cls.bulk_edit_data = { + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + +class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = FrontPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + + rearports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 4'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'), + ) + RearPortTemplate.objects.bulk_create(rearports) + + FrontPortTemplate.objects.bulk_create(( + FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1), + )) + + cls.form_data = { + 'device_type': devicetype.pk, + 'name': 'Front Port X', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rearports[3].pk, + 'rear_port_position': 1, + } + + cls.bulk_create_data = { + 'device_type': devicetype.pk, + 'name_pattern': 'Front Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port_set': [ + '{}:1'.format(rp.pk) for rp in rearports[3:6] + ], + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + } + + +class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = RearPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + RearPortTemplate.objects.bulk_create(( + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Rear Port Template X', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 2, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Rear Port Template [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 2, + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + } + + +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = DeviceBayTemplate + + # Disable inapplicable views + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + DeviceBayTemplate.objects.bulk_create(( + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'), + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'), + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Device Bay Template X', + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Device Bay Template [4-6]', + } + + +class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = DeviceRole + + @classmethod + def setUpTestData(cls): DeviceRole.objects.bulk_create([ DeviceRole(name='Device Role 1', slug='device-role-1'), @@ -506,156 +825,143 @@ class DeviceRoleTestCase(TestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) - def test_devicerole_list(self): + cls.form_data = { + 'name': 'Devie Role X', + 'slug': 'device-role-x', + 'color': 'c0c0c0', + 'vm_role': False, + 'description': 'New device role', + } - url = reverse('dcim:devicerole_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_devicerole_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug,color", "Device Role 4,device-role-4,ff0000", "Device Role 5,device-role-5,00ff00", "Device Role 6,device-role-6,0000ff", ) - response = self.client.post(reverse('dcim:devicerole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(DeviceRole.objects.count(), 6) +class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Platform + @classmethod + def setUpTestData(cls): -class PlatformTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_platform', - 'dcim.add_platform', - ] - ) - self.client = Client() - self.client.force_login(user) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') Platform.objects.bulk_create([ - Platform(name='Platform 1', slug='platform-1'), - Platform(name='Platform 2', slug='platform-2'), - Platform(name='Platform 3', slug='platform-3'), + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) - def test_platform_list(self): + cls.form_data = { + 'name': 'Platform X', + 'slug': 'platform-x', + 'manufacturer': manufacturer.pk, + 'napalm_driver': 'junos', + 'napalm_args': None, + } - url = reverse('dcim:platform_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_platform_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Platform 4,platform-4", "Platform 5,platform-5", "Platform 6,platform-6", ) - response = self.client.post(reverse('dcim:platform_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(Platform.objects.count(), 6) +class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Device + @classmethod + def setUpTestData(cls): -class DeviceTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_device', - 'dcim.add_device', - ] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.client = Client() - self.client.force_login(user) + Site.objects.bulk_create(sites) - site = Site(name='Site 1', slug='site-1') - site.save() + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() + devicetypes = ( + DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer), + DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer), + ) + DeviceType.objects.bulk_create(devicetypes) - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() + deviceroles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + ) + DeviceRole.objects.bulk_create(deviceroles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + ) + Platform.objects.bulk_create(platforms) Device.objects.bulk_create([ - Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), - Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), - Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), + Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), + Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), ]) - def test_device_list(self): - - url = reverse('dcim:device_list') - params = { - "device_type_id": DeviceType.objects.first().pk, - "role": DeviceRole.objects.first().slug, + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'device_role': deviceroles[1].pk, + 'tenant': None, + 'platform': platforms[1].pk, + 'name': 'Device X', + 'serial': '123456', + 'asset_tag': 'ABCDEF', + 'site': sites[1].pk, + 'rack': racks[1].pk, + 'position': 1, + 'face': DeviceFaceChoices.FACE_FRONT, + 'status': DeviceStatusChoices.STATUS_PLANNED, + 'primary_ip4': None, + 'primary_ip6': None, + 'cluster': None, + 'virtual_chassis': None, + 'vc_position': None, + 'vc_priority': None, + 'comments': 'A new device', + 'tags': 'Alpha,Bravo,Charlie', + 'local_context_data': None, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_device(self): - - device = Device.objects.first() - response = self.client.get(device.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_device_import(self): - - csv_data = ( + cls.csv_data = ( "device_role,manufacturer,model_name,status,site,name", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 4", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 5", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6", ) - response = self.client.post(reverse('dcim:device_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Device.objects.count(), 6) + cls.bulk_edit_data = { + 'device_type': devicetypes[1].pk, + 'device_role': deviceroles[1].pk, + 'tenant': None, + 'platform': platforms[1].pk, + 'serial': '123456', + 'status': DeviceStatusChoices.STATUS_DECOMMISSIONING, + } -class ConsolePortTestCase(TestCase): +class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = ConsolePort - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleport', - 'dcim.add_consoleport', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') ConsolePort.objects.bulk_create([ ConsolePort(device=device, name='Console Port 1'), @@ -663,54 +969,41 @@ class ConsolePortTestCase(TestCase): ConsolePort(device=device, name='Console Port 3'), ]) - def test_consoleport_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Console Port X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console port', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:consoleport_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console port', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'New description', + } - def test_consoleport_import(self): - - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Console Port 4", "Device 1,Console Port 5", "Device 1,Console Port 6", ) - response = self.client.post(reverse('dcim:consoleport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(ConsolePort.objects.count(), 6) +class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = ConsoleServerPort - -class ConsoleServerPortTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleserverport', - 'dcim.add_consoleserverport', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') ConsoleServerPort.objects.bulk_create([ ConsoleServerPort(device=device, name='Console Server Port 1'), @@ -718,54 +1011,42 @@ class ConsoleServerPortTestCase(TestCase): ConsoleServerPort(device=device, name='Console Server Port 3'), ]) - def test_consoleserverport_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Console Server Port X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console server port', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:consoleserverport_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Server Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console server port', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'device': device.pk, + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'New description', + } - def test_consoleserverport_import(self): - - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Console Server Port 4", "Device 1,Console Server Port 5", "Device 1,Console Server Port 6", ) - response = self.client.post(reverse('dcim:consoleserverport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(ConsoleServerPort.objects.count(), 6) +class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = PowerPort - -class PowerPortTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_powerport', - 'dcim.add_powerport', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') PowerPort.objects.bulk_create([ PowerPort(device=device, name='Power Port 1'), @@ -773,231 +1054,240 @@ class PowerPortTestCase(TestCase): PowerPort(device=device, name='Power Port 3'), ]) - def test_powerport_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Power Port X', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'A power port', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:powerport_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Port [4-6]]', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'A power port', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'New description', + } - def test_powerport_import(self): - - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Power Port 4", "Device 1,Power Port 5", "Device 1,Power Port 6", ) - response = self.client.post(reverse('dcim:powerport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(PowerPort.objects.count(), 6) +class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = PowerOutlet + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') -class PowerOutletTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_poweroutlet', - 'dcim.add_poweroutlet', - ] + powerports = ( + PowerPort(device=device, name='Power Port 1'), + PowerPort(device=device, name='Power Port 2'), ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + PowerPort.objects.bulk_create(powerports) PowerOutlet.objects.bulk_create([ - PowerOutlet(device=device, name='Power Outlet 1'), - PowerOutlet(device=device, name='Power Outlet 2'), - PowerOutlet(device=device, name='Power Outlet 3'), + PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]), + PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]), + PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), ]) - def test_poweroutlet_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Power Outlet X', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'A power outlet', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:poweroutlet_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Outlet [4-6]', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'A power outlet', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'device': device.pk, + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'New description', + } - def test_poweroutlet_import(self): - - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Power Outlet 4", "Device 1,Power Outlet 5", "Device 1,Power Outlet 6", ) - response = self.client.post(reverse('dcim:poweroutlet_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(PowerOutlet.objects.count(), 6) +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): + model = Interface + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') -class InterfaceTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_interface', - 'dcim.add_interface', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() - - Interface.objects.bulk_create([ + interfaces = ( Interface(device=device, name='Interface 1'), Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), - ]) + Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + ) + Interface.objects.bulk_create(interfaces) - def test_interface_list(self): + vlans = ( + VLAN(vid=1, name='VLAN1', site=device.site), + VLAN(vid=101, name='VLAN101', site=device.site), + VLAN(vid=102, name='VLAN102', site=device.site), + VLAN(vid=103, name='VLAN103', site=device.site), + ) + VLAN.objects.bulk_create(vlans) - url = reverse('dcim:interface_list') + cls.form_data = { + 'device': device.pk, + 'virtual_machine': None, + 'name': 'Interface X', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'A front port', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Interface [4-6]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'A front port', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } - def test_interface_import(self): + cls.bulk_edit_data = { + 'device': device.pk, + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'New description', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + } - csv_data = ( + cls.csv_data = ( "device,name,type", "Device 1,Interface 4,1000BASE-T (1GE)", "Device 1,Interface 5,1000BASE-T (1GE)", "Device 1,Interface 6,1000BASE-T (1GE)", ) - response = self.client.post(reverse('dcim:interface_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(Interface.objects.count(), 6) +class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = FrontPort + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') -class FrontPortTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_frontport', - 'dcim.add_frontport', - ] + rearports = ( + RearPort(device=device, name='Rear Port 1'), + RearPort(device=device, name='Rear Port 2'), + RearPort(device=device, name='Rear Port 3'), + RearPort(device=device, name='Rear Port 4'), + RearPort(device=device, name='Rear Port 5'), + RearPort(device=device, name='Rear Port 6'), ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() - - rearport1 = RearPort(device=device, name='Rear Port 1') - rearport1.save() - rearport2 = RearPort(device=device, name='Rear Port 2') - rearport2.save() - rearport3 = RearPort(device=device, name='Rear Port 3') - rearport3.save() - - # RearPorts for CSV import test - RearPort(device=device, name='Rear Port 4').save() - RearPort(device=device, name='Rear Port 5').save() - RearPort(device=device, name='Rear Port 6').save() + RearPort.objects.bulk_create(rearports) FrontPort.objects.bulk_create([ - FrontPort(device=device, name='Front Port 1', rear_port=rearport1), - FrontPort(device=device, name='Front Port 2', rear_port=rearport2), - FrontPort(device=device, name='Front Port 3', rear_port=rearport3), + FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]), + FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]), + FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), ]) - def test_frontport_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Front Port X', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rearports[3].pk, + 'rear_port_position': 1, + 'description': 'New description', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:frontport_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Front Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port_set': [ + '{}:1'.format(rp.pk) for rp in rearports[3:6] + ], + 'description': 'New description', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + 'description': 'New description', + } - def test_frontport_import(self): - - csv_data = ( + cls.csv_data = ( "device,name,type,rear_port,rear_port_position", "Device 1,Front Port 4,8P8C,Rear Port 4,1", "Device 1,Front Port 5,8P8C,Rear Port 5,1", "Device 1,Front Port 6,8P8C,Rear Port 6,1", ) - response = self.client.post(reverse('dcim:frontport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(FrontPort.objects.count(), 6) +class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = RearPort - -class RearPortTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rearport', - 'dcim.add_rearport', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') RearPort.objects.bulk_create([ RearPort(device=device, name='Rear Port 1'), @@ -1005,113 +1295,86 @@ class RearPortTestCase(TestCase): RearPort(device=device, name='Rear Port 3'), ]) - def test_rearport_list(self): + cls.form_data = { + 'device': device.pk, + 'name': 'Rear Port X', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 3, + 'description': 'A rear port', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:rearport_list') + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Rear Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 3, + 'description': 'A rear port', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + 'description': 'New description', + } - def test_rearport_import(self): - - csv_data = ( + cls.csv_data = ( "device,name,type,positions", "Device 1,Rear Port 4,8P8C,1", "Device 1,Rear Port 5,8P8C,1", "Device 1,Rear Port 6,8P8C,1", ) - response = self.client.post(reverse('dcim:rearport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(RearPort.objects.count(), 6) +class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = DeviceBay + # Disable inapplicable views + test_bulk_edit_objects = None -class DeviceBayTestCase(TestCase): + @classmethod + def setUpTestData(cls): + device1 = create_test_device('Device 1') + device2 = create_test_device('Device 2') - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_devicebay', - 'dcim.add_devicebay', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType( - model='Device Type 1', - manufacturer=manufacturer, - subdevice_role=SubdeviceRoleChoices.ROLE_PARENT - ) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + # Update the DeviceType subdevice role to allow adding DeviceBays + DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) DeviceBay.objects.bulk_create([ - DeviceBay(device=device, name='Device Bay 1'), - DeviceBay(device=device, name='Device Bay 2'), - DeviceBay(device=device, name='Device Bay 3'), + DeviceBay(device=device1, name='Device Bay 1'), + DeviceBay(device=device1, name='Device Bay 2'), + DeviceBay(device=device1, name='Device Bay 3'), ]) - def test_devicebay_list(self): + cls.form_data = { + 'device': device2.pk, + 'name': 'Device Bay X', + 'description': 'A device bay', + 'tags': 'Alpha,Bravo,Charlie', + } - url = reverse('dcim:devicebay_list') + cls.bulk_create_data = { + 'device': device2.pk, + 'name_pattern': 'Device Bay [4-6]', + 'description': 'A device bay', + 'tags': 'Alpha,Bravo,Charlie', + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_devicebay_import(self): - - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Device Bay 4", "Device 1,Device Bay 5", "Device 1,Device Bay 6", ) - response = self.client.post(reverse('dcim:devicebay_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(DeviceBay.objects.count(), 6) +class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = InventoryItem - -class InventoryItemTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_inventoryitem', - 'dcim.add_inventoryitem', - ] - ) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') InventoryItem.objects.bulk_create([ InventoryItem(device=device, name='Inventory Item 1'), @@ -1119,126 +1382,135 @@ class InventoryItemTestCase(TestCase): InventoryItem(device=device, name='Inventory Item 3'), ]) - def test_inventoryitem_list(self): - - url = reverse('dcim:inventoryitem_list') - params = { - "device_id": Device.objects.first().pk, + cls.form_data = { + 'device': device.pk, + 'manufacturer': manufacturer.pk, + 'name': 'Inventory Item X', + 'parent': None, + 'discovered': False, + 'part_id': '123456', + 'serial': '123ABC', + 'asset_tag': 'ABC123', + 'description': 'An inventory item', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Inventory Item [4-6]', + 'manufacturer': manufacturer.pk, + 'parent': None, + 'discovered': False, + 'part_id': '123456', + 'serial': '123ABC', + 'description': 'An inventory item', + 'tags': 'Alpha,Bravo,Charlie', + } - def test_inventoryitem_import(self): + cls.bulk_edit_data = { + 'device': device.pk, + 'manufacturer': manufacturer.pk, + 'part_id': '123456', + 'description': 'New description', + } - csv_data = ( + cls.csv_data = ( "device,name", "Device 1,Inventory Item 4", "Device 1,Inventory Item 5", "Device 1,Inventory Item 6", ) - response = self.client.post(reverse('dcim:inventoryitem_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(InventoryItem.objects.count(), 6) +class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Cable + # TODO: Creation URL needs termination context + test_create_object = None -class CableTestCase(TestCase): + @classmethod + def setUpTestData(cls): - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_cable', - 'dcim.add_cable', - ] + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole), ) - self.client = Client() - self.client.force_login(user) + Device.objects.bulk_create(devices) - site = Site(name='Site 1', slug='site-1') - site.save() + interfaces = ( + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(interfaces) - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() + Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() + Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save() - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device1.save() - device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) - device2.save() - device3 = Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole) - device3.save() - device4 = Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole) - device4.save() - - iface1 = Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface1.save() - iface2 = Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface2.save() - iface3 = Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface3.save() - iface4 = Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface4.save() - iface5 = Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface5.save() - iface6 = Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface6.save() - - # Interfaces for CSV import testing - Interface(device=device3, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device3, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device3, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - - Cable(termination_a=iface1, termination_b=iface4, type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=iface2, termination_b=iface5, type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=iface3, termination_b=iface6, type=CableTypeChoices.TYPE_CAT6).save() - - def test_cable_list(self): - - url = reverse('dcim:cable_list') - params = { - "type": CableTypeChoices.TYPE_CAT6, + interface_ct = ContentType.objects.get_for_model(Interface) + cls.form_data = { + # Changing terminations not supported when editing an existing Cable + 'termination_a_type': interface_ct.pk, + 'termination_a_id': interfaces[0].pk, + 'termination_b_type': interface_ct.pk, + 'termination_b_id': interfaces[3].pk, + 'type': CableTypeChoices.TYPE_CAT6, + 'status': CableStatusChoices.STATUS_PLANNED, + 'label': 'Label', + 'color': 'c0c0c0', + 'length': 100, + 'length_unit': CableLengthUnitChoices.UNIT_FOOT, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_cable(self): - - cable = Cable.objects.first() - response = self.client.get(cable.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_cable_import(self): - - csv_data = ( + cls.csv_data = ( "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", "Device 3,interface,Interface 1,Device 4,interface,Interface 1", "Device 3,interface,Interface 2,Device 4,interface,Interface 2", "Device 3,interface,Interface 3,Device 4,interface,Interface 3", ) - response = self.client.post(reverse('dcim:cable_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Cable.objects.count(), 6) + cls.bulk_edit_data = { + 'type': CableTypeChoices.TYPE_CAT5E, + 'status': CableStatusChoices.STATUS_CONNECTED, + 'label': 'New label', + 'color': '00ff00', + 'length': 50, + 'length_unit': CableLengthUnitChoices.UNIT_METER, + } -class VirtualChassisTestCase(TestCase): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VirtualChassis - def setUp(self): - user = create_test_user(permissions=['dcim.view_virtualchassis']) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_import_objects = None + test_bulk_edit_objects = None + test_bulk_delete_objects = None + + # TODO: Requires special form handling + test_create_object = None + test_edit_object = None + + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') @@ -1277,9 +1549,110 @@ class VirtualChassisTestCase(TestCase): vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) - def test_virtualchassis_list(self): - url = reverse('dcim:virtualchassis_list') +class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = PowerPanel - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + # Disable inapplicable tests + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + rackgroups = ( + RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), + ) + RackGroup.objects.bulk_create(rackgroups) + + PowerPanel.objects.bulk_create(( + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'), + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'), + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'), + )) + + cls.form_data = { + 'site': sites[1].pk, + 'rack_group': rackgroups[1].pk, + 'name': 'Power Panel X', + } + + cls.csv_data = ( + "site,rack_group_name,name", + "Site 1,Rack Group 1,Power Panel 4", + "Site 1,Rack Group 1,Power Panel 5", + "Site 1,Rack Group 1,Power Panel 6", + ) + + +class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = PowerFeed + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1', slug='site-1') + + powerpanels = ( + PowerPanel(site=site, name='Power Panel 1'), + PowerPanel(site=site, name='Power Panel 2'), + ) + PowerPanel.objects.bulk_create(powerpanels) + + racks = ( + Rack(site=site, name='Rack 1'), + Rack(site=site, name='Rack 2'), + ) + Rack.objects.bulk_create(racks) + + PowerFeed.objects.bulk_create(( + PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]), + PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]), + PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), + )) + + cls.form_data = { + 'name': 'Power Feed X', + 'power_panel': powerpanels[1].pk, + 'rack': racks[1].pk, + 'status': PowerFeedStatusChoices.STATUS_PLANNED, + 'type': PowerFeedTypeChoices.TYPE_REDUNDANT, + 'supply': PowerFeedSupplyChoices.SUPPLY_DC, + 'phase': PowerFeedPhaseChoices.PHASE_3PHASE, + 'voltage': 100, + 'amperage': 100, + 'max_utilization': 50, + 'comments': 'New comments', + 'tags': 'Alpha,Bravo,Charlie', + + # Connection + 'cable': None, + 'connected_endpoint': None, + 'connection_status': None, + } + + cls.csv_data = ( + "site,panel_name,name,voltage,amperage,max_utilization", + "Site 1,Power Panel 1,Power Feed 4,120,20,80", + "Site 1,Power Panel 1,Power Feed 5,120,20,80", + "Site 1,Power Panel 1,Power Feed 6,120,20,80", + ) + + cls.bulk_edit_data = { + 'power_panel': powerpanels[1].pk, + 'rack': racks[1].pk, + 'status': PowerFeedStatusChoices.STATUS_PLANNED, + 'type': PowerFeedTypeChoices.TYPE_REDUNDANT, + 'supply': PowerFeedSupplyChoices.SUPPLY_DC, + 'phase': PowerFeedPhaseChoices.PHASE_3PHASE, + 'voltage': 100, + 'amperage': 100, + 'max_utilization': 50, + 'comments': 'New comments', + } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 956b49bc4..165ca9e02 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -14,317 +14,338 @@ app_name = 'dcim' urlpatterns = [ # Regions - path(r'regions/', views.RegionListView.as_view(), name='region_list'), - path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'), - path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), - path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), - path(r'regions//edit/', views.RegionEditView.as_view(), name='region_edit'), - path(r'regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path('regions/', views.RegionListView.as_view(), name='region_list'), + path('regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites - path(r'sites/', views.SiteListView.as_view(), name='site_list'), - path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'), - path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), - path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), - path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path(r'sites//', views.SiteView.as_view(), name='site'), - path(r'sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path(r'sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path(r'sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - path(r'sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), + path('sites/', views.SiteListView.as_view(), name='site_list'), + path('sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), + path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), + path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), + path('sites//', views.SiteView.as_view(), name='site'), + path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups - path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), - path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - path(r'rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - path(r'rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), + path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), + path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), + path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles - path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), - path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), - path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), - path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), - path(r'rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), - path(r'rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), + path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations - path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), - path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), - path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), - path(r'rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), - path(r'rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - path(r'rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), + path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), + path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), + path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks - path(r'racks/', views.RackListView.as_view(), name='rack_list'), - path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), - path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'), - path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), - path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), - path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - path(r'racks//', views.RackView.as_view(), name='rack'), - path(r'racks//edit/', views.RackEditView.as_view(), name='rack_edit'), - path(r'racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), - path(r'racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - path(r'racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), - path(r'racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), + path('racks/', views.RackListView.as_view(), name='rack_list'), + path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), + path('racks/add/', views.RackCreateView.as_view(), name='rack_add'), + path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), + path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), + path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), + path('racks//', views.RackView.as_view(), name='rack'), + path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), + path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), + path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path('racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), + path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers - path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), - path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), - path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), - path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path(r'manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path(r'manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types - path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), - path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), - path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), - path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - path(r'device-types//', views.DeviceTypeView.as_view(), name='devicetype'), - path(r'device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), - path(r'device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - path(r'device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), + path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), + path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), + path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), + path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), + path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates - path(r'device-types//console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), - path(r'device-types//console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), - path(r'console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), + path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), + path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), + path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'), # Console server port templates - path(r'device-types//console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), - path(r'device-types//console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), - path(r'console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), + path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), + path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), + path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'), # Power port templates - path(r'device-types//power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), - path(r'device-types//power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), - path(r'power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), + path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), + path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), + path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'), # Power outlet templates - path(r'device-types//power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), - path(r'device-types//power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), - path(r'power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), + path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), + path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), + path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'), # Interface templates - path(r'device-types//interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), - path(r'device-types//interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), - path(r'device-types//interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), - path(r'interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), + path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), + path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), + path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'), # Front port templates - path(r'device-types//front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), - path(r'device-types//front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), - path(r'front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), + path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), + path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), + path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'), # Rear port templates - path(r'device-types//rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), - path(r'device-types//rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), - path(r'rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), + path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), + path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), + path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'), # Device bay templates - path(r'device-types//device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), - path(r'device-types//device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), - path(r'device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), + # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), + path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), + path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), # Device roles - path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), - path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), - path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), - path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path(r'device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path(r'device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), + path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms - path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'), - path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), - path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), - path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path(r'platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path(r'platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms/', views.PlatformListView.as_view(), name='platform_list'), + path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices - path(r'devices/', views.DeviceListView.as_view(), name='device_list'), - path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'), - path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), - path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), - path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), - path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - path(r'devices//', views.DeviceView.as_view(), name='device'), - path(r'devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), - path(r'devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), - path(r'devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path(r'devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path(r'devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), - path(r'devices//status/', views.DeviceStatusView.as_view(), name='device_status'), - path(r'devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - path(r'devices//config/', views.DeviceConfigView.as_view(), name='device_config'), - path(r'devices//add-secret/', secret_add, name='device_addsecret'), - path(r'devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), - path(r'devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), + path('devices/', views.DeviceListView.as_view(), name='device_list'), + path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), + path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), + path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), + path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), + path('devices//', views.DeviceView.as_view(), name='device'), + path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), + path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), + path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), + path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), + path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path('devices//add-secret/', secret_add, name='device_addsecret'), + path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports - path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), - path(r'devices//console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), - path(r'devices//console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), - path(r'console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), - path(r'console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - path(r'console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - path(r'console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), - path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), + path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), + path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), + path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), + path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), + # TODO: Bulk rename, disconnect views for ConsolePorts + path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), + path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), + path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports - path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), - path(r'devices//console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), - path(r'devices//console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), - path(r'devices//console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'), - path(r'console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), - path(r'console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), - path(r'console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - path(r'console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), - path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), - path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'), + path('console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'), + path('console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + path('console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'), + path('console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), + path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), + path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), + path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), + path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports - path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), - path(r'devices//power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), - path(r'devices//power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'), - path(r'power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), - path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), - path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - path(r'power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), - path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), + path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'), + path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), + path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), + path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), + # TODO: Bulk rename, disconnect views for PowerPorts + path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), + path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), + path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets - path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), - path(r'devices//power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), - path(r'devices//power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), - path(r'devices//power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'), - path(r'power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), - path(r'power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), - path(r'power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), - path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), - path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'), + path('power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'), + path('power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + path('power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'), + path('power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), + path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), + path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), + path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), + path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces - path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), - path(r'devices//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), - path(r'devices//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - path(r'devices//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'), - path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), - path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), - path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), - path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), - path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'), + path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'), + path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), + path('interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'), + path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path('interfaces//', views.InterfaceView.as_view(), name='interface'), + path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), + path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), + path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), + path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports - # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), - path(r'devices//front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), - path(r'devices//front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), - path(r'devices//front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'), - path(r'front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), - path(r'front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), - path(r'front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), - path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), - path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'), + path('front-ports/', views.FrontPortListView.as_view(), name='frontport_list'), + path('front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), + path('front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'), + path('front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), + path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), + path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports - # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), - path(r'devices//rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), - path(r'devices//rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), - path(r'devices//rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'), - path(r'rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), - path(r'rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), - path(r'rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), - path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), - path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), - path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'), + path('rear-ports/', views.RearPortListView.as_view(), name='rearport_list'), + path('rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), + path('rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'), + path('rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), + path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), + path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), + path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Device bays - path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), - path(r'devices//bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), - path(r'devices//bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), - path(r'device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - path(r'device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - path(r'device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), - path(r'device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), - path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), - path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'), + path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), + path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), + path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'), + # TODO: Bulk edit view for DeviceBays + path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), + path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), + path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), + path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), # Inventory items - path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), - path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), - path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), - path(r'inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), - path(r'inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - path(r'devices//inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + path('inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), + path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'), + path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), + path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), + # TODO: Bulk rename view for InventoryItems + path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), # Cables - path(r'cables/', views.CableListView.as_view(), name='cable_list'), - path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), - path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), - path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), - path(r'cables//', views.CableView.as_view(), name='cable'), - path(r'cables//edit/', views.CableEditView.as_view(), name='cable_edit'), - path(r'cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), - path(r'cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path('cables/', views.CableListView.as_view(), name='cable_list'), + path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), + path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + path('cables//', views.CableView.as_view(), name='cable'), + path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), + path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), + path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), # Console/power/interface connections (read-only) - path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), + path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), + path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), + path('interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), # Virtual chassis - path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), - path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), - path(r'virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), - path(r'virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - path(r'virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - path(r'virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), - path(r'virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), # Power panels - path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), - path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), - path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), - path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), - path(r'power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), - path(r'power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), - path(r'power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), - path(r'power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), + path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), + path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), + path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), + path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), + path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), + path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), # Power feeds - path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), - path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), - path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), - path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), - path(r'power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), - path(r'power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), - path(r'power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), - path(r'power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), + path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), + path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), + path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), + path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), + path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), + path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fd3d09ab7..0bb6658a2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -152,7 +152,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RegionFilterSet filterset_form = forms.RegionFilterForm table = tables.RegionTable - template_name = 'dcim/region_list.html' class RegionCreateView(PermissionRequiredMixin, ObjectEditView): @@ -191,7 +190,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SiteFilterSet filterset_form = forms.SiteFilterForm table = tables.SiteTable - template_name = 'dcim/site_list.html' class SiteView(PermissionRequiredMixin, View): @@ -271,7 +269,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackGroupFilterSet filterset_form = forms.RackGroupFilterForm table = tables.RackGroupTable - template_name = 'dcim/rackgroup_list.html' class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -308,7 +305,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rackrole' queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable - template_name = 'dcim/rackrole_list.html' class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -350,7 +346,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackFilterSet filterset_form = forms.RackFilterForm table = tables.RackDetailTable - template_name = 'dcim/rack_list.html' class RackElevationListView(PermissionRequiredMixin, View): @@ -474,7 +469,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable - template_name = 'dcim/rackreservation_list.html' + action_buttons = () class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): @@ -533,7 +528,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView): platform_count=Count('platforms', distinct=True), ) table = tables.ManufacturerTable - template_name = 'dcim/manufacturer_list.html' class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): @@ -571,7 +565,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable - template_name = 'dcim/devicetype_list.html' class DeviceTypeView(PermissionRequiredMixin, View): @@ -700,13 +693,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Device type components +# Console port templates # class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = ConsolePortTemplate form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm @@ -719,17 +710,30 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.ConsolePortTemplateForm +class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleporttemplate' + model = ConsolePortTemplate + + +class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleporttemplate' + queryset = ConsolePortTemplate.objects.all() + table = tables.ConsolePortTemplateTable + form = forms.ConsolePortTemplateBulkEditForm + + class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleporttemplate' queryset = ConsolePortTemplate.objects.all() - parent_model = DeviceType table = tables.ConsolePortTemplateTable +# +# Console server port templates +# + class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = ConsoleServerPortTemplate form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm @@ -742,17 +746,30 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView) model_form = forms.ConsoleServerPortTemplateForm +class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleserverporttemplate' + model = ConsoleServerPortTemplate + + +class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleserverporttemplate' + queryset = ConsoleServerPortTemplate.objects.all() + table = tables.ConsoleServerPortTemplateTable + form = forms.ConsoleServerPortTemplateBulkEditForm + + class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverporttemplate' queryset = ConsoleServerPortTemplate.objects.all() - parent_model = DeviceType table = tables.ConsoleServerPortTemplateTable +# +# Power port templates +# + class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = PowerPortTemplate form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm @@ -765,17 +782,30 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerPortTemplateForm +class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerporttemplate' + model = PowerPortTemplate + + +class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerporttemplate' + queryset = PowerPortTemplate.objects.all() + table = tables.PowerPortTemplateTable + form = forms.PowerPortTemplateBulkEditForm + + class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' queryset = PowerPortTemplate.objects.all() - parent_model = DeviceType table = tables.PowerPortTemplateTable +# +# Power outlet templates +# + class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' - parent_model = DeviceType - parent_field = 'device_type' model = PowerOutletTemplate form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm @@ -788,17 +818,30 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerOutletTemplateForm +class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_poweroutlettemplate' + model = PowerOutletTemplate + + +class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_poweroutlettemplate' + queryset = PowerOutletTemplate.objects.all() + table = tables.PowerOutletTemplateTable + form = forms.PowerOutletTemplateBulkEditForm + + class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlettemplate' queryset = PowerOutletTemplate.objects.all() - parent_model = DeviceType table = tables.PowerOutletTemplateTable +# +# Interface templates +# + class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' - parent_model = DeviceType - parent_field = 'device_type' model = InterfaceTemplate form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm @@ -811,10 +854,14 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.InterfaceTemplateForm +class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interfacetemplate' + model = InterfaceTemplate + + class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' queryset = InterfaceTemplate.objects.all() - parent_model = DeviceType table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm @@ -822,14 +869,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' queryset = InterfaceTemplate.objects.all() - parent_model = DeviceType table = tables.InterfaceTemplateTable +# +# Front port templates +# + class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = FrontPortTemplate form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm @@ -842,17 +890,30 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.FrontPortTemplateForm +class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + model = FrontPortTemplate + + +class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_frontporttemplate' + queryset = FrontPortTemplate.objects.all() + table = tables.FrontPortTemplateTable + form = forms.FrontPortTemplateBulkEditForm + + class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_frontporttemplate' queryset = FrontPortTemplate.objects.all() - parent_model = DeviceType table = tables.FrontPortTemplateTable +# +# Rear port templates +# + class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = RearPortTemplate form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm @@ -865,17 +926,30 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.RearPortTemplateForm +class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + model = RearPortTemplate + + +class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_rearporttemplate' + queryset = RearPortTemplate.objects.all() + table = tables.RearPortTemplateTable + form = forms.RearPortTemplateBulkEditForm + + class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rearporttemplate' queryset = RearPortTemplate.objects.all() - parent_model = DeviceType table = tables.RearPortTemplateTable +# +# Device bay templates +# + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' - parent_model = DeviceType - parent_field = 'device_type' model = DeviceBayTemplate form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm @@ -888,10 +962,21 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.DeviceBayTemplateForm +class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_devicebaytemplate' + model = DeviceBayTemplate + + +# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): +# permission_required = 'dcim.change_devicebaytemplate' +# queryset = DeviceBayTemplate.objects.all() +# table = tables.DeviceBayTemplateTable +# form = forms.DeviceBayTemplateBulkEditForm + + class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' queryset = DeviceBayTemplate.objects.all() - parent_model = DeviceType table = tables.DeviceBayTemplateTable @@ -903,7 +988,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_devicerole' queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable - template_name = 'dcim/devicerole_list.html' class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -939,7 +1023,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_platform' queryset = Platform.objects.all() table = tables.PlatformTable - template_name = 'dcim/platform_list.html' class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): @@ -1200,13 +1283,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleport' - parent_model = Device - parent_field = 'device' model = ConsolePort form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm @@ -1231,11 +1312,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:consoleport_list' +class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleport' + queryset = ConsolePort.objects.all() + table = tables.ConsolePortTable + form = forms.ConsolePortBulkEditForm + + class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' queryset = ConsolePort.objects.all() - parent_model = Device table = tables.ConsolePortTable + default_return_url = 'dcim:consoleport_list' # @@ -1248,13 +1336,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverport' - parent_model = Device - parent_field = 'device' model = ConsoleServerPort form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm @@ -1282,7 +1368,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleserverport' queryset = ConsoleServerPort.objects.all() - parent_model = Device table = tables.ConsoleServerPortTable form = forms.ConsoleServerPortBulkEditForm @@ -1302,8 +1387,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' queryset = ConsoleServerPort.objects.all() - parent_model = Device table = tables.ConsoleServerPortTable + default_return_url = 'dcim:consoleserverport_list' # @@ -1316,13 +1401,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerport' - parent_model = Device - parent_field = 'device' model = PowerPort form = forms.PowerPortCreateForm model_form = forms.PowerPortForm @@ -1347,11 +1430,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:powerport_list' +class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerport' + queryset = PowerPort.objects.all() + table = tables.PowerPortTable + form = forms.PowerPortBulkEditForm + + class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' queryset = PowerPort.objects.all() - parent_model = Device table = tables.PowerPortTable + default_return_url = 'dcim:powerport_list' # @@ -1364,13 +1454,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlet' - parent_model = Device - parent_field = 'device' model = PowerOutlet form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm @@ -1398,7 +1486,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_poweroutlet' queryset = PowerOutlet.objects.all() - parent_model = Device table = tables.PowerOutletTable form = forms.PowerOutletBulkEditForm @@ -1418,8 +1505,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' queryset = PowerOutlet.objects.all() - parent_model = Device table = tables.PowerOutletTable + default_return_url = 'dcim:poweroutlet_list' # @@ -1432,7 +1519,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class InterfaceView(PermissionRequiredMixin, View): @@ -1473,8 +1560,6 @@ class InterfaceView(PermissionRequiredMixin, View): class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interface' - parent_model = Device - parent_field = 'device' model = Interface form = forms.InterfaceCreateForm model_form = forms.InterfaceForm @@ -1503,7 +1588,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' queryset = Interface.objects.all() - parent_model = Device table = tables.InterfaceTable form = forms.InterfaceBulkEditForm @@ -1523,8 +1607,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' queryset = Interface.objects.all() - parent_model = Device table = tables.InterfaceTable + default_return_url = 'dcim:interface_list' # @@ -1537,13 +1621,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontport' - parent_model = Device - parent_field = 'device' model = FrontPort form = forms.FrontPortCreateForm model_form = forms.FrontPortForm @@ -1571,7 +1653,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_frontport' queryset = FrontPort.objects.all() - parent_model = Device table = tables.FrontPortTable form = forms.FrontPortBulkEditForm @@ -1591,8 +1672,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_frontport' queryset = FrontPort.objects.all() - parent_model = Device table = tables.FrontPortTable + default_return_url = 'dcim:frontport_list' # @@ -1605,13 +1686,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearport' - parent_model = Device - parent_field = 'device' model = RearPort form = forms.RearPortCreateForm model_form = forms.RearPortForm @@ -1639,7 +1718,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rearport' queryset = RearPort.objects.all() - parent_model = Device table = tables.RearPortTable form = forms.RearPortBulkEditForm @@ -1659,8 +1737,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rearport' queryset = RearPort.objects.all() - parent_model = Device table = tables.RearPortTable + default_return_url = 'dcim:rearport_list' # @@ -1675,13 +1753,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayDetailTable - template_name = 'dcim/device_component_list.html' + action_buttons = ('import', 'export') class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebay' - parent_model = Device - parent_field = 'device' model = DeviceBay form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm @@ -1784,8 +1860,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' queryset = DeviceBay.objects.all() - parent_model = Device table = tables.DeviceBayTable + default_return_url = 'dcim:devicebay_list' # @@ -1876,7 +1952,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable - template_name = 'dcim/cable_list.html' + action_buttons = ('import', 'export') class CableView(PermissionRequiredMixin, View): @@ -2148,7 +2224,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_list.html' + action_buttons = ('import', 'export') class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): @@ -2156,13 +2232,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): model = InventoryItem model_form = forms.InventoryItemForm - def alter_obj(self, obj, request, url_args, url_kwargs): - if 'device' in url_kwargs: - obj.device = get_object_or_404(Device, pk=url_kwargs['device']) - return obj - def get_return_url(self, request, obj): - return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) +class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_inventoryitem' + model = InventoryItem + form = forms.InventoryItemCreateForm + model_form = forms.InventoryItemForm + template_name = 'dcim/device_component_add.html' class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): @@ -2204,7 +2280,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm - template_name = 'dcim/virtualchassis_list.html' + action_buttons = ('export',) class VirtualChassisCreateView(PermissionRequiredMixin, View): @@ -2448,7 +2524,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPanelFilterSet filterset_form = forms.PowerPanelFilterForm table = tables.PowerPanelTable - template_name = 'dcim/powerpanel_list.html' class PowerPanelView(PermissionRequiredMixin, View): @@ -2517,7 +2592,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerFeedFilterSet filterset_form = forms.PowerFeedFilterForm table = tables.PowerFeedTable - template_name = 'dcim/powerfeed_list.html' class PowerFeedView(PermissionRequiredMixin, View): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0e27a8ee5..58433df25 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -20,6 +20,8 @@ from utilities.api import ( ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField, ValidatedModelSerializer, ) +from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer +from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * @@ -161,6 +163,18 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + cluster_groups = SerializedPKRelatedField( + queryset=ClusterGroup.objects.all(), + serializer=NestedClusterGroupSerializer, + required=False, + many=True + ) + clusters = SerializedPKRelatedField( + queryset=Cluster.objects.all(), + serializer=NestedClusterSerializer, + required=False, + many=True + ) tenant_groups = SerializedPKRelatedField( queryset=TenantGroup.objects.all(), serializer=NestedTenantGroupSerializer, @@ -184,7 +198,7 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'tenant_groups', 'tenants', 'tags', 'data', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 50a54d3fe..d699cd22e 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -15,34 +15,34 @@ router = routers.DefaultRouter() router.APIRootView = ExtrasRootView # Field choices -router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') # Custom field choices -router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') +router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') # Graphs -router.register(r'graphs', views.GraphViewSet) +router.register('graphs', views.GraphViewSet) # Export templates -router.register(r'export-templates', views.ExportTemplateViewSet) +router.register('export-templates', views.ExportTemplateViewSet) # Tags -router.register(r'tags', views.TagViewSet) +router.register('tags', views.TagViewSet) # Image attachments -router.register(r'image-attachments', views.ImageAttachmentViewSet) +router.register('image-attachments', views.ImageAttachmentViewSet) # Config contexts -router.register(r'config-contexts', views.ConfigContextViewSet) +router.register('config-contexts', views.ConfigContextViewSet) # Reports -router.register(r'reports', views.ReportViewSet, basename='report') +router.register('reports', views.ReportViewSet, basename='report') # Scripts -router.register(r'scripts', views.ScriptViewSet, basename='script') +router.register('scripts', views.ScriptViewSet, basename='script') # Change logging -router.register(r'object-changes', views.ObjectChangeViewSet) +router.register('object-changes', views.ObjectChangeViewSet) app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index f8c5a98e6..3201c3bb2 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,28 +1,8 @@ from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -import redis class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.signals - - # Check that we can connect to the configured Redis database. - try: - rs = redis.Redis( - host=settings.WEBHOOKS_REDIS_HOST, - port=settings.WEBHOOKS_REDIS_PORT, - db=settings.WEBHOOKS_REDIS_DATABASE, - password=settings.WEBHOOKS_REDIS_PASSWORD or None, - ssl=settings.WEBHOOKS_REDIS_SSL, - ) - rs.ping() - except redis.exceptions.ConnectionError: - raise ImproperlyConfigured( - "Unable to connect to the Redis database. Check that the Redis configuration has been defined in " - "configuration.py." - ) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 8a0d32b33..dcd4f3ede 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -170,6 +171,22 @@ class ConfigContextFilterSet(django_filters.FilterSet): to_field_name='slug', label='Platform (slug)', ) + cluster_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_groups', + queryset=ClusterGroup.objects.all(), + label='Cluster group', + ) + cluster_group = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_groups__slug', + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Cluster group (slug)', + ) + cluster_id = django_filters.ModelMultipleChoiceFilter( + field_name='clusters', + queryset=Cluster.objects.all(), + label='Cluster', + ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups', queryset=TenantGroup.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index edde6c6c5..d6a5406b7 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,18 +1,17 @@ -from collections import OrderedDict - from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist +from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, - SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) +from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -21,102 +20,41 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen # Custom fields # -def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): - """ - Retrieve all CustomFields applicable to the given ContentType - """ - field_dict = OrderedDict() - custom_fields = CustomField.objects.filter(obj_type=content_type) - if filterable_only: - custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) - - for cf in custom_fields: - field_name = 'cf_{}'.format(str(cf.name)) - initial = cf.default if not bulk_edit else None - - # Integer - if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: - field = forms.IntegerField(required=cf.required, initial=initial) - - # Boolean - elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - choices = ( - (None, '---------'), - (1, 'True'), - (0, 'False'), - ) - if initial is not None and initial.lower() in ['true', 'yes', '1']: - initial = 1 - elif initial is not None and initial.lower() in ['false', 'no', '0']: - initial = 0 - else: - initial = None - field = forms.NullBooleanField( - required=cf.required, initial=initial, widget=StaticSelect2(choices=choices) - ) - - # Date - elif cf.type == CustomFieldTypeChoices.TYPE_DATE: - field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker()) - - # Select - elif cf.type == CustomFieldTypeChoices.TYPE_SELECT: - choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if not cf.required or bulk_edit or filterable_only: - choices = [(None, '---------')] + choices - # Check for a default choice - default_choice = None - if initial: - try: - default_choice = cf.choices.get(value=initial).pk - except ObjectDoesNotExist: - pass - field = forms.TypedChoiceField( - choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() - ) - - # URL - elif cf.type == CustomFieldTypeChoices.TYPE_URL: - field = LaxURLField(required=cf.required, initial=initial) - - # Text - else: - field = forms.CharField(max_length=255, required=cf.required, initial=initial) - - field.model = cf - field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() - if cf.description: - field.help_text = cf.description - - field_dict[field_name] = field - - return field_dict - - -class CustomFieldForm(forms.ModelForm): +class CustomFieldModelForm(forms.ModelForm): def __init__(self, *args, **kwargs): - self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self._meta.model) + self.custom_fields = [] + self.custom_field_values = {} super().__init__(*args, **kwargs) - # Add all applicable CustomFields to the form - custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type).items(): - self.fields[name] = field - custom_fields.append(name) - self.custom_fields = custom_fields + self._append_customfield_fields() - # If editing an existing object, initialize values for all custom fields + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this model. + """ + # Retrieve initial CustomField values for the instance if self.instance.pk: - existing_values = CustomFieldValue.objects.filter( + for cfv in CustomFieldValue.objects.filter( obj_type=self.obj_type, obj_id=self.instance.pk - ).prefetch_related('field') - for cfv in existing_values: - self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value + ).prefetch_related('field'): + self.custom_field_values[cfv.field.name] = cfv.serialized_value + + # Append form fields; assign initial values if modifying and existing object + for cf in CustomField.objects.filter(obj_type=self.obj_type): + field_name = 'cf_{}'.format(cf.name) + if self.instance.pk: + self.fields[field_name] = cf.to_form_field(set_initial=False) + self.fields[field_name].initial = self.custom_field_values.get(cf.name) + else: + self.fields[field_name] = cf.to_form_field() + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) def _save_custom_fields(self): @@ -151,6 +89,19 @@ class CustomFieldForm(forms.ModelForm): return obj +class CustomFieldModelCSVForm(CustomFieldModelForm): + + def _append_customfield_fields(self): + + # Append form fields + for cf in CustomField.objects.filter(obj_type=self.obj_type): + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field(for_csv_import=True) + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) + + class CustomFieldBulkEditForm(BulkEditForm): def __init__(self, *args, **kwargs): @@ -160,15 +111,14 @@ class CustomFieldBulkEditForm(BulkEditForm): self.obj_type = ContentType.objects.get_for_model(self.model) # Add all applicable CustomFields to the form - custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items() - for name, field in custom_fields: + custom_fields = CustomField.objects.filter(obj_type=self.obj_type) + for cf in custom_fields: # Annotate non-required custom fields as nullable - if not field.required: - self.nullable_fields.append(name) - field.required = False - self.fields[name] = field + if not cf.required: + self.nullable_fields.append(cf.name) + self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) # Annotate this as a custom field - self.custom_fields.append(name) + self.custom_fields.append(cf.name) class CustomFieldFilterForm(forms.Form): @@ -180,10 +130,12 @@ class CustomFieldFilterForm(forms.Form): super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form - custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() - for name, field in custom_fields: - field.required = False - self.fields[name] = field + custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude( + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + ) + for cf in custom_fields: + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) # @@ -239,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): - tags = forms.ModelMultipleChoiceField( + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2Multiple() + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/sites/" + ) + ) + roles = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/" + ) + ) + platforms = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/platforms/" + ) + ) + cluster_groups = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ) + ) + clusters = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/" + ) + ) + tenant_groups = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/" + ) + ) + tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/" + ) + ) + tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', required=False, @@ -253,30 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext - fields = [ - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', - 'tenants', 'tags', 'data', - ] - widgets = { - 'regions': APISelectMultiple( - api_url="/api/dcim/regions/" - ), - 'sites': APISelectMultiple( - api_url="/api/dcim/sites/" - ), - 'roles': APISelectMultiple( - api_url="/api/dcim/device-roles/" - ), - 'platforms': APISelectMultiple( - api_url="/api/dcim/platforms/" - ), - 'tenant_groups': APISelectMultiple( - api_url="/api/tenancy/tenant-groups/" - ), - 'tenants': APISelectMultiple( - api_url="/api/tenancy/tenants/" - ), - } + fields = ( + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', + 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + ) class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): @@ -308,57 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", ) ) - tenant_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/", + value_field="slug", + ) + ) + cluster_id = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + label='Cluster', + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/", + ) + ) + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", ) ) - tag = FilterChoiceField( + tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/extras/tags/", value_field="slug", @@ -415,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): ) action = forms.ChoiceField( choices=add_blank_choice(ObjectChangeActionChoices), - required=False + required=False, + widget=StaticSelect2() ) + # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), - required=False + required=False, + widget=StaticSelect2() ) changed_object_type = forms.ModelChoiceField( queryset=ContentType.objects.order_by('model'), diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py new file mode 100644 index 000000000..cfd037910 --- /dev/null +++ b/netbox/extras/management/commands/renaturalize.py @@ -0,0 +1,111 @@ +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError + +from utilities.fields import NaturalOrderingField + + +class Command(BaseCommand): + help = "Recalculate natural ordering values for the specified models" + + def add_arguments(self, parser): + parser.add_argument( + 'args', metavar='app_label.ModelName', nargs='*', + help='One or more specific models (each prefixed with its app_label) to renaturalize', + ) + + def _get_models(self, names): + """ + Compile a list of models to be renaturalized. If no names are specified, all models which have one or more + NaturalOrderingFields will be included. + """ + models = [] + + if names: + # Collect all NaturalOrderingFields present on the specified models + for name in names: + try: + app_label, model_name = name.split('.') + except ValueError: + raise CommandError( + "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name) + ) + try: + app_config = apps.get_app_config(app_label) + except LookupError as e: + raise CommandError(str(e)) + try: + model = app_config.get_model(model_name) + except LookupError: + raise CommandError("Unknown model: {}.{}".format(app_label, model_name)) + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if not fields: + raise CommandError( + "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name) + ) + models.append( + (model, fields) + ) + + else: + # Find *all* models with NaturalOrderingFields + for app_config in apps.get_app_configs(): + for model in app_config.models.values(): + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if fields: + models.append( + (model, fields) + ) + + return models + + def handle(self, *args, **options): + + models = self._get_models(args) + + if options['verbosity']: + self.stdout.write("Renaturalizing {} models.".format(len(models))) + + for model, fields in models: + for field in fields: + target_field = field.target_field + naturalize = field.naturalize_function + count = 0 + + # Print the model and field name + if options['verbosity']: + self.stdout.write( + "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name), + ending='\n' if options['verbosity'] >= 2 else '' + ) + self.stdout.flush() + + # Find all unique values for the field + queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct() + for value in queryset: + naturalized_value = naturalize(value, max_length=field.max_length) + + if options['verbosity'] >= 2: + self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') + self.stdout.flush() + + # Update each unique field value in bulk + changed = model.objects.filter(name=value).update(**{field.name: naturalized_value}) + + if options['verbosity'] >= 2: + self.stdout.write(" ({})".format(changed)) + count += changed + + # Print the total count of alterations for the field + if options['verbosity'] >= 2: + self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format( + count, model._meta.verbose_name_plural, queryset.count() + ))) + elif options['verbosity']: + self.stdout.write(self.style.SUCCESS(str(count))) + + if options['verbosity']: + self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/netbox/extras/migrations/0037_configcontexts_clusters.py b/netbox/extras/migrations/0037_configcontexts_clusters.py new file mode 100644 index 000000000..201aed94a --- /dev/null +++ b/netbox/extras/migrations/0037_configcontexts_clusters.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.8 on 2020-01-17 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0013_deterministic_ordering'), + ('extras', '0036_contenttype_filters_to_q_objects'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='cluster_groups', + field=models.ManyToManyField(blank=True, related_name='_configcontext_cluster_groups_+', to='virtualization.ClusterGroup'), + ), + migrations.AddField( + model_name='configcontext', + name='clusters', + field=models.ManyToManyField(blank=True, related_name='_configcontext_clusters_+', to='virtualization.Cluster'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a03494bb2..5d175d172 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,6 +1,7 @@ from collections import OrderedDict from datetime import date +from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -14,6 +15,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from utilities.fields import ColorField +from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * @@ -280,6 +282,75 @@ class CustomField(models.Model): return self.choices.get(pk=int(serialized_value)) return serialized_value + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): + """ + Return a form field suitable for setting a CustomField's value for an object. + + set_initial: Set initial date for the field. This should be False when generating a field for bulk editing. + enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. + for_csv_import: Return a form field suitable for bulk import of objects in CSV format. + """ + initial = self.default if set_initial else None + required = self.required if enforce_required else False + + # Integer + if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + field = forms.IntegerField(required=required, initial=initial) + + # Boolean + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + choices = ( + (None, '---------'), + (1, 'True'), + (0, 'False'), + ) + if initial is not None and initial.lower() in ['true', 'yes', '1']: + initial = 1 + elif initial is not None and initial.lower() in ['false', 'no', '0']: + initial = 0 + else: + initial = None + field = forms.NullBooleanField( + required=required, initial=initial, widget=StaticSelect2(choices=choices) + ) + + # Date + elif self.type == CustomFieldTypeChoices.TYPE_DATE: + field = forms.DateField(required=required, initial=initial, widget=DatePicker()) + + # Select + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] + + if not required: + choices = add_blank_choice(choices) + + # Set the initial value to the PK of the default choice, if any + if set_initial: + default_choice = self.choices.filter(value=self.default).first() + if default_choice: + initial = default_choice.pk + + field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2() + ) + + # URL + elif self.type == CustomFieldTypeChoices.TYPE_URL: + field = LaxURLField(required=required, initial=initial) + + # Text + else: + field = forms.CharField(max_length=255, required=required, initial=initial) + + field.model = self + field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() + if self.description: + field.help_text = self.description + + return field + class CustomFieldValue(models.Model): field = models.ForeignKey( @@ -694,6 +765,16 @@ class ConfigContext(models.Model): related_name='+', blank=True ) + cluster_groups = models.ManyToManyField( + to='virtualization.ClusterGroup', + related_name='+', + blank=True + ) + clusters = models.ManyToManyField( + to='virtualization.Cluster', + related_name='+', + blank=True + ) tenant_groups = models.ManyToManyField( to='tenancy.TenantGroup', related_name='+', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 22ab489bd..812c66714 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -29,6 +29,10 @@ class ConfigContextQuerySet(QuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role + # Virtualization cluster for VirtualMachine + cluster = getattr(obj, 'cluster', None) + cluster_group = getattr(cluster, 'group', None) + # Get the group of the assigned tenant, if any tenant_group = obj.tenant.group if obj.tenant else None @@ -44,6 +48,8 @@ class ConfigContextQuerySet(QuerySet): Q(sites=obj.site) | Q(sites=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), + Q(cluster_groups=cluster_group) | Q(cluster_groups=None), + Q(clusters=cluster) | Q(clusters=None), Q(tenant_groups=tenant_group) | Q(tenant_groups=None), Q(tenants=obj.tenant) | Q(tenants=None), Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 6567fe707..e5a32bde6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -48,7 +48,7 @@ class ScriptVariable: """ form_field = forms.CharField - def __init__(self, label='', description='', default=None, required=True): + def __init__(self, label='', description='', default=None, required=True, widget=None): # Initialize field attributes if not hasattr(self, 'field_attrs'): @@ -59,6 +59,8 @@ class ScriptVariable: self.field_attrs['help_text'] = description if default: self.field_attrs['initial'] = default + if widget: + self.field_attrs['widget'] = widget self.field_attrs['required'] = required # Initialize the list of optional validators if none have already been defined @@ -71,7 +73,10 @@ class ScriptVariable: """ form_field = self.form_field(**self.field_attrs) if not isinstance(form_field.widget, forms.CheckboxInput): - form_field.widget.attrs['class'] = 'form-control' + if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys(): + form_field.widget.attrs['class'] += ' form-control' + else: + form_field.widget.attrs['class'] = 'form-control' return form_field diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 192958840..a6e2bfcec 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,14 +1,15 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from rest_framework import status +from dcim.forms import SiteCSVForm from dcim.models import Site from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from utilities.testing import APITestCase +from utilities.testing import APITestCase, create_test_user from virtualization.models import VirtualMachine @@ -364,3 +365,113 @@ class CustomFieldChoiceAPITest(APITestCase): self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value]) self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value]) self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) + + +class CustomFieldImportTest(TestCase): + + def setUp(self): + + user = create_test_user( + permissions=[ + 'dcim.view_site', + 'dcim.add_site', + ] + ) + self.client = Client() + self.client.force_login(user) + + @classmethod + def setUpTestData(cls): + + custom_fields = ( + CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), + CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), + CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), + CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT), + ) + for cf in custom_fields: + cf.save() + cf.obj_type.set([ContentType.objects.get_for_model(Site)]) + + CustomFieldChoice.objects.bulk_create(( + CustomFieldChoice(field=custom_fields[5], value='Choice A'), + CustomFieldChoice(field=custom_fields[5], value='Choice B'), + CustomFieldChoice(field=custom_fields[5], value='Choice C'), + )) + + def test_import(self): + """ + Import a Site in CSV format, including a value for each CustomField. + """ + data = ( + ('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), + ('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), + ('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), + ('Site 3', 'site-3', '', '', '', '', '', ''), + ) + csv_data = '\n'.join(','.join(row) for row in data) + + response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) + self.assertEqual(response.status_code, 200) + + # Validate data for site 1 + custom_field_values = { + cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items() + } + self.assertEqual(len(custom_field_values), 6) + self.assertEqual(custom_field_values['text'], 'ABC') + self.assertEqual(custom_field_values['integer'], 123) + self.assertEqual(custom_field_values['boolean'], True) + self.assertEqual(custom_field_values['date'], date(2020, 1, 1)) + self.assertEqual(custom_field_values['url'], 'http://example.com/1') + self.assertEqual(custom_field_values['select'].value, 'Choice A') + + # Validate data for site 2 + custom_field_values = { + cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items() + } + self.assertEqual(len(custom_field_values), 6) + self.assertEqual(custom_field_values['text'], 'DEF') + self.assertEqual(custom_field_values['integer'], 456) + self.assertEqual(custom_field_values['boolean'], False) + self.assertEqual(custom_field_values['date'], date(2020, 1, 2)) + self.assertEqual(custom_field_values['url'], 'http://example.com/2') + self.assertEqual(custom_field_values['select'].value, 'Choice B') + + # No CustomFieldValues should be created for site 3 + obj_type = ContentType.objects.get_for_model(Site) + site3 = Site.objects.get(name='Site 3') + self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists()) + self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check + + def test_import_missing_required(self): + """ + Attempt to import an object missing a required custom field. + """ + # Set one of our CustomFields to required + CustomField.objects.filter(name='text').update(required=True) + + form_data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + form = SiteCSVForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('cf_text', form.errors) + + def test_import_invalid_choice(self): + """ + Attempt to import an object with an invalid choice selection. + """ + form_data = { + 'name': 'Site 1', + 'slug': 'site-1', + 'cf_select': 'Choice X' + } + + form = SiteCSVForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('cf_select', form.errors) diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 130f94298..5ef96faa2 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -7,6 +7,7 @@ from extras.constants import GRAPH_MODELS from extras.filters import * from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup +from virtualization.models import Cluster, ClusterGroup, ClusterType class GraphTestCase(TestCase): @@ -107,6 +108,21 @@ class ConfigContextTestCase(TestCase): ) Platform.objects.bulk_create(platforms) + cluster_groups = ( + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ) + ClusterGroup.objects.bulk_create(cluster_groups) + + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + clusters = ( + Cluster(name='Cluster 1', type=cluster_type), + Cluster(name='Cluster 2', type=cluster_type), + Cluster(name='Cluster 3', type=cluster_type), + ) + Cluster.objects.bulk_create(clusters) + tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), @@ -132,6 +148,8 @@ class ConfigContextTestCase(TestCase): c.sites.set([sites[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) + c.cluster_groups.set([cluster_groups[i]]) + c.clusters.set([clusters[i]]) c.tenant_groups.set([tenant_groups[i]]) c.tenants.set([tenants[i]]) @@ -173,6 +191,18 @@ class ConfigContextTestCase(TestCase): params = {'platform': [platforms[0].slug, platforms[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cluster_group(self): + cluster_groups = ClusterGroup.objects.all()[:2] + params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cluster(self): + clusters = Cluster.objects.all()[:2] + params = {'cluster_id': [clusters[0].pk, clusters[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant_group(self): tenant_groups = TenantGroup.objects.all()[:2] params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 792390121..370055b26 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -2,86 +2,102 @@ import urllib.parse import uuid from django.contrib.auth.models import User -from django.test import Client, TestCase from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import ConfigContext, ObjectChange, Tag -from utilities.testing import create_test_user +from utilities.testing import ViewTestCases, TestCase -class TagTestCase(TestCase): +class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Tag - def setUp(self): - user = create_test_user(permissions=['extras.view_tag']) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_create_object = None + test_import_objects = None - Tag.objects.bulk_create([ + @classmethod + def setUpTestData(cls): + + Tag.objects.bulk_create(( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 3', slug='tag-3'), - ]) + )) - def test_tag_list(self): - - url = reverse('extras:tag_list') - params = { - "q": "tag", + cls.form_data = { + 'name': 'Tag X', + 'slug': 'tag-x', + 'color': 'c0c0c0', + 'comments': 'Some comments', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'color': '00ff00', + } -class ConfigContextTestCase(TestCase): +class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ConfigContext - def setUp(self): - user = create_test_user(permissions=['extras.view_configcontext']) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_import_objects = None - site = Site(name='Site 1', slug='site-1') - site.save() + # TODO: Resolve model discrepancies when creating/editing ConfigContexts + test_create_object = None + test_edit_object = None + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1', slug='site-1') # Create three ConfigContexts for i in range(1, 4): configcontext = ConfigContext( name='Config Context {}'.format(i), - data='{{"foo": {}}}'.format(i) + data={'foo': i} ) configcontext.save() configcontext.sites.add(site) - def test_configcontext_list(self): - - url = reverse('extras:configcontext_list') - params = { - "q": "foo", + cls.form_data = { + 'name': 'Config Context X', + 'weight': 200, + 'description': 'A new config context', + 'is_active': True, + 'regions': [], + 'sites': [site.pk], + 'roles': [], + 'platforms': [], + 'tenant_groups': [], + 'tenants': [], + 'tags': [], + 'data': '{"foo": 123}', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_configcontext(self): - - configcontext = ConfigContext.objects.first() - response = self.client.get(configcontext.get_absolute_url()) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'weight': 300, + 'is_active': False, + 'description': 'New description', + } +# TODO: Convert to StandardTestCases.Views class ObjectChangeTestCase(TestCase): + user_permissions = ( + 'extras.view_objectchange', + ) - def setUp(self): - user = create_test_user(permissions=['extras.view_objectchange']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() # Create three ObjectChanges + user = User.objects.create_user(username='testuser2') for i in range(1, 4): oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE) oc.user = user @@ -96,10 +112,10 @@ class ObjectChangeTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_objectchange(self): objectchange = ObjectChange.objects.first() response = self.client.get(objectchange.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 653fe7c7f..a486ce7fc 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -8,38 +8,38 @@ app_name = 'extras' urlpatterns = [ # Tags - path(r'tags/', views.TagListView.as_view(), name='tag_list'), - path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), - path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path(r'tags//', views.TagView.as_view(), name='tag'), - path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path('tags/', views.TagListView.as_view(), name='tag_list'), + path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), + path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path('tags//', views.TagView.as_view(), name='tag'), + path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts - path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), - path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), - path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), - path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), - path(r'config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), - path(r'config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), - path(r'config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), + path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), + path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), + path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), + path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), # Image attachments - path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), - path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), # Change logging - path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path('changelog//', views.ObjectChangeView.as_view(), name='objectchange'), # Reports - path(r'reports/', views.ReportListView.as_view(), name='report_list'), - path(r'reports//', views.ReportView.as_view(), name='report'), - path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), + path('reports/', views.ReportListView.as_view(), name='report_list'), + path('reports//', views.ReportView.as_view(), name='report'), + path('reports//run/', views.ReportRunView.as_view(), name='report_run'), # Scripts - path(r'scripts/', views.ScriptListView.as_view(), name='script_list'), - path(r'scripts///', views.ScriptView.as_view(), name='script'), + path('scripts/', views.ScriptListView.as_view(), name='script_list'), + path('scripts///', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2fce98cc4..3912c602f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -34,10 +34,11 @@ class TagListView(PermissionRequiredMixin, ObjectListView): filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm table = TagTable - template_name = 'extras/tag_list.html' + action_buttons = () -class TagView(View): +class TagView(PermissionRequiredMixin, View): + permission_required = 'extras.view_tag' def get(self, request, slug): @@ -84,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView): ).order_by( 'name' ) - # filter = filters.ProviderFilter table = TagTable form = forms.TagBulkEditForm - default_return_url = 'circuits:provider_list' + default_return_url = 'extras:tag_list' class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -111,7 +111,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = ConfigContextTable - template_name = 'extras/configcontext_list.html' + action_buttons = ('add',) class ConfigContextView(PermissionRequiredMixin, View): @@ -191,6 +191,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = ObjectChangeTable template_name = 'extras/objectchange_list.html' + action_buttons = ('export',) class ObjectChangeView(PermissionRequiredMixin, View): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e52c172e5..e6d9adecd 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) @@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceField(choices=ServiceProtocolChoices) + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=NestedIPAddressSerializer, diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 9a2e1bc1f..c4d68f9c0 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -15,30 +15,30 @@ router = routers.DefaultRouter() router.APIRootView = IPAMRootView # Field choices -router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') # VRFs -router.register(r'vrfs', views.VRFViewSet) +router.register('vrfs', views.VRFViewSet) # RIRs -router.register(r'rirs', views.RIRViewSet) +router.register('rirs', views.RIRViewSet) # Aggregates -router.register(r'aggregates', views.AggregateViewSet) +router.register('aggregates', views.AggregateViewSet) # Prefixes -router.register(r'roles', views.RoleViewSet) -router.register(r'prefixes', views.PrefixViewSet) +router.register('roles', views.RoleViewSet) +router.register('prefixes', views.PrefixViewSet) # IP addresses -router.register(r'ip-addresses', views.IPAddressViewSet) +router.register('ip-addresses', views.IPAddressViewSet) # VLANs -router.register(r'vlan-groups', views.VLANGroupViewSet) -router.register(r'vlans', views.VLANViewSet) +router.register('vlan-groups', views.VLANGroupViewSet) +router.register('vlans', views.VLANViewSet) # Services -router.register(r'services', views.ServiceViewSet) +router.register('services', views.ServiceViewSet) app_name = 'ipam-api' urlpatterns = router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 08e21367c..262ca7908 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db.models import Count from django.shortcuts import get_object_or_404 +from django_pglocks import advisory_lock from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers @@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet): filterset_class = filters.PrefixFilterSet @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def available_prefixes(self, request, pk=None): """ A convenience method for returning available child prefixes within a parent. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) available_prefixes = prefix.get_available_prefixes() @@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) @action(detail=True, url_path='available-ips', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): """ A convenience method for returning available IP addresses within a prefix. By default, the number of IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, however results will not be paginated. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..5f8bcabff 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -304,12 +304,12 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.CharFilter( + device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device', + label='Device (name)', ) - device_id = django_filters.NumberFilter( + device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', label='Device (ID)', @@ -385,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF def filter_device(self, queryset, name, value): try: - device = Device.objects.prefetch_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] + devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) + vc_interface_ids = [] + for device in devices: + vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) return queryset.filter(interface_id__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 183fcb717..2b7fb2a6b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,13 +4,16 @@ from django.core.validators import MaxValueValidator, MinValueValidator from taggit.forms import TagField from dcim.models import Device, Interface, Rack, Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, +) from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, - CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -31,7 +34,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # VRFs # -class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): tags = TagField( required=False ) @@ -49,7 +52,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VRFCSVForm(forms.ModelForm): +class VRFCSVForm(CustomFieldModelCSVForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -73,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -103,6 +106,7 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) + tag = TagFilterField(model) # @@ -144,7 +148,13 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # Aggregates # -class AggregateForm(BootstrapMixin, CustomFieldForm): +class AggregateForm(BootstrapMixin, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + widget=APISelect( + api_url="/api/ipam/rirs/" + ) + ) tags = TagField( required=False ) @@ -159,14 +169,11 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): 'rir': "Regional Internet Registry responsible for this prefix", } widgets = { - 'rir': APISelect( - api_url="/api/ipam/rirs/" - ), 'date_added': DatePicker(), } -class AggregateCSVForm(forms.ModelForm): +class AggregateCSVForm(CustomFieldModelCSVForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -186,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() ) - rir = forms.ModelChoiceField( + rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, label='RIR', @@ -223,15 +230,17 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Address family', widget=StaticSelect2() ) - rir = FilterChoiceField( + rir = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), to_field_name='slug', + required=False, label='RIR', widget=APISelectMultiple( api_url="/api/ipam/rirs/", value_field="slug", ) ) + tag = TagFilterField(model) # @@ -263,11 +272,17 @@ class RoleCSVForm(forms.ModelForm): # Prefixes # -class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): - site = forms.ModelChoiceField( +class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vrfs/", + ) + ) + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', widget=APISelect( api_url="/api/dcim/sites/", filter_for={ @@ -279,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - vlan_group = ChainedModelChoiceField( + vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, label='VLAN group', widget=APISelect( @@ -296,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - vlan = ChainedModelChoiceField( + vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), required=False, label='VLAN', widget=APISelect( @@ -309,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): display_field='display_name' ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -318,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'tags', ] widgets = { - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } def __init__(self, *args, **kwargs): @@ -341,7 +350,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class PrefixCSVForm(forms.ModelForm): +class PrefixCSVForm(CustomFieldModelCSVForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -435,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -455,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF max_value=PREFIX_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -467,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -521,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -535,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -547,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -578,18 +587,27 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, label='Expand prefix hierarchy' ) + tag = TagFilterField(model) # # IP addresses # -class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): interface = forms.ModelChoiceField( queryset=Interface.objects.all(), required=False ) - nat_site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', @@ -601,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) } ) ) - nat_rack = ChainedModelChoiceField( + nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'nat_site'), - ), required=False, label='Rack', widget=APISelect( @@ -619,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) } ) ) - nat_device = ChainedModelChoiceField( + nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'nat_site'), - ('rack', 'nat_rack'), - ), required=False, label='Device', widget=APISelect( @@ -646,11 +657,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) } ) ) - nat_inside = ChainedModelChoiceField( + nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - chains=( - ('interface__device', 'nat_device'), - ), required=False, label='IP Address', widget=APISelect( @@ -675,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -751,7 +756,15 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): ) -class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) class Meta: model = IPAddress @@ -761,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -771,7 +781,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class IPAddressCSVForm(forms.ModelForm): +class IPAddressCSVForm(CustomFieldModelCSVForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -899,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -912,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -945,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf_id = forms.ModelChoiceField( + vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -991,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -1017,6 +1027,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) # @@ -1024,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: @@ -1031,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'name', 'slug', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } class VLANGroupCSVForm(forms.ModelForm): @@ -1059,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1071,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1087,8 +1100,8 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): - site = forms.ModelChoiceField( +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -1101,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } ) ) - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, - label='Group', widget=APISelect( api_url='/api/ipam/vlan-groups/', ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -1129,13 +1145,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } widgets = { 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } -class VLANCSVForm(forms.ModelForm): +class VLANCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1206,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlan-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -1232,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -1257,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1270,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), + required=False, label='VLAN group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", null_option=True, @@ -1294,23 +1307,24 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", null_option=True, ) ) + tag = TagFilterField(model) # # Services # -class ServiceForm(BootstrapMixin, CustomFieldForm): +class ServiceForm(BootstrapMixin, CustomFieldModelForm): port = forms.IntegerField( min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX @@ -1364,6 +1378,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): port = forms.IntegerField( required=False, ) + tag = TagFilterField(model) class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -1390,5 +1405,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class Meta: nullable_fields = [ - 'site', 'tenant', 'role', 'description', + 'description', ] diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 80ada0df8..4737a0f53 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # TODO: Test for multiple values def test_device(self): - device = Device.objects.first() - params = {'device_id': device.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'device': device.name} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_virtual_machine(self): vms = VirtualMachine.objects.all()[:2] diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py new file mode 100644 index 000000000..153bedddc --- /dev/null +++ b/netbox/ipam/tests/test_ordering.py @@ -0,0 +1,176 @@ +from django.test import TestCase + +from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices +from ipam.models import IPAddress, Prefix, VRF + +import netaddr + + +class OrderingTestBase(TestCase): + vrfs = None + + def setUp(self): + """ + Setup the VRFs for the class as a whole + """ + self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) + VRF.objects.bulk_create(self.vrfs) + + def _compare(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + for i, obj in enumerate(queryset): + self.assertEqual(obj, objectset[i]) + + def _compare_ne(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + for i, obj in enumerate(queryset): + self.assertNotEqual(obj, objectset[i]) + + +class PrefixOrderingTestCase(OrderingTestBase): + + def test_prefix_vrf_ordering(self): + """ + This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Prefixes + prefixes = ( + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.5.0/24')), + + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.4.0/24')), + + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/12')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.4.0/24')), + ) + + Prefix.objects.bulk_create(prefixes) + + # Test + self._compare(Prefix.objects.all(), prefixes) + + def test_prefix_complex_ordering(self): + """ + This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs + This includes the testing of the Container status. + + The proper ordering, to get proper containerization should be: + None:10.0.0.0/8 + None:10.0.0.0/16 + VRF A:10.0.0.0/24 + VRF A:10.0.1.0/24 + VRF A:10.0.1.0/25 + None:10.1.0.0/16 + VRF A:10.1.0.0/24 + VRF A:10.1.1.0/24 + None: 192.168.0.0/16 + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Prefixes + prefixes = [ + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/25')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), + ] + Prefix.objects.bulk_create(prefixes) + + # Test + self._compare(Prefix.objects.all(), prefixes) + + +class IPAddressOrderingTestCase(OrderingTestBase): + + def test_address_vrf_ordering(self): + """ + This function tests ordering with the inclusion of vrfs + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Addresses + addresses = ( + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.4.1/24')), + + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.4.1/24')), + + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.5.1/24')), + ) + IPAddress.objects.bulk_create(addresses) + + # Test + self._compare(IPAddress.objects.all(), addresses) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 6f08f2d47..66e649005 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,26 +1,18 @@ -from netaddr import IPNetwork -import urllib.parse +import datetime -from django.test import Client, TestCase -from django.urls import reverse +from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site -from ipam.choices import ServiceProtocolChoices +from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import create_test_user +from utilities.testing import ViewTestCases -class VRFTestCase(TestCase): +class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VRF - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vrf', - 'ipam.add_vrf', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): VRF.objects.bulk_create([ VRF(name='VRF 1', rd='65000:1'), @@ -28,48 +20,34 @@ class VRFTestCase(TestCase): VRF(name='VRF 3', rd='65000:3'), ]) - def test_vrf_list(self): - - url = reverse('ipam:vrf_list') - params = { - "q": "65000", + cls.form_data = { + 'name': 'VRF X', + 'rd': '65000:999', + 'tenant': None, + 'enforce_unique': True, + 'description': 'A new VRF', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_vrf(self): - - vrf = VRF.objects.first() - response = self.client.get(vrf.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_vrf_import(self): - - csv_data = ( + cls.csv_data = ( "name", "VRF 4", "VRF 5", "VRF 6", ) - response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(VRF.objects.count(), 6) + cls.bulk_edit_data = { + 'tenant': None, + 'enforce_unique': False, + 'description': 'New description', + } -class RIRTestCase(TestCase): +class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = RIR - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_rir', - 'ipam.add_rir', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): RIR.objects.bulk_create([ RIR(name='RIR 1', slug='rir-1'), @@ -77,91 +55,66 @@ class RIRTestCase(TestCase): RIR(name='RIR 3', slug='rir-3'), ]) - def test_rir_list(self): + cls.form_data = { + 'name': 'RIR X', + 'slug': 'rir-x', + 'is_private': True, + } - url = reverse('ipam:rir_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_rir_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "RIR 4,rir-4", "RIR 5,rir-5", "RIR 6,rir-6", ) - response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(RIR.objects.count(), 6) +class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Aggregate + @classmethod + def setUpTestData(cls): -class AggregateTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_aggregate', - 'ipam.add_aggregate', - ] + rirs = ( + RIR(name='RIR 1', slug='rir-1'), + RIR(name='RIR 2', slug='rir-2'), ) - self.client = Client() - self.client.force_login(user) - - rir = RIR(name='RIR 1', slug='rir-1') - rir.save() + RIR.objects.bulk_create(rirs) Aggregate.objects.bulk_create([ - Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir), - Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir), - Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir), + Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]), + Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]), + Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), ]) - def test_aggregate_list(self): - - url = reverse('ipam:aggregate_list') - params = { - "rir": RIR.objects.first().slug, + cls.form_data = { + 'family': 4, + 'prefix': IPNetwork('10.99.0.0/16'), + 'rir': rirs[1].pk, + 'date_added': datetime.date(2020, 1, 1), + 'description': 'A new aggregate', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_aggregate(self): - - aggregate = Aggregate.objects.first() - response = self.client.get(aggregate.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_aggregate_import(self): - - csv_data = ( + cls.csv_data = ( "prefix,rir", "10.4.0.0/16,RIR 1", "10.5.0.0/16,RIR 1", "10.6.0.0/16,RIR 1", ) - response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Aggregate.objects.count(), 6) + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'date_added': datetime.date(2020, 1, 1), + 'description': 'New description', + } -class RoleTestCase(TestCase): +class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Role - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_role', - 'ipam.add_role', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Role.objects.bulk_create([ Role(name='Role 1', slug='role-1'), @@ -169,146 +122,135 @@ class RoleTestCase(TestCase): Role(name='Role 3', slug='role-3'), ]) - def test_role_list(self): + cls.form_data = { + 'name': 'Role X', + 'slug': 'role-x', + 'weight': 200, + 'description': 'A new role', + } - url = reverse('ipam:role_list') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_role_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug,weight", "Role 4,role-4,1000", "Role 5,role-5,1000", "Role 6,role-6,1000", ) - response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(Role.objects.count(), 6) +class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Prefix + @classmethod + def setUpTestData(cls): -class PrefixTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_prefix', - 'ipam.add_prefix', - ] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.client = Client() - self.client.force_login(user) + Site.objects.bulk_create(sites) - site = Site(name='Site 1', slug='site-1') - site.save() + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + ) + VRF.objects.bulk_create(vrfs) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) Prefix.objects.bulk_create([ - Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site), - Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site), - Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site), + Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), + Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), + Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), ]) - def test_prefix_list(self): - - url = reverse('ipam:prefix_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'prefix': IPNetwork('192.0.2.0/24'), + 'site': sites[1].pk, + 'vrf': vrfs[1].pk, + 'tenant': None, + 'vlan': None, + 'status': PrefixStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'is_pool': True, + 'description': 'A new prefix', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_prefix(self): - - prefix = Prefix.objects.first() - response = self.client.get(prefix.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_prefix_import(self): - - csv_data = ( + cls.csv_data = ( "prefix,status", "10.4.0.0/16,Active", "10.5.0.0/16,Active", "10.6.0.0/16,Active", ) - response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(Prefix.objects.count(), 6) - - -class IPAddressTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_ipaddress', - 'ipam.add_ipaddress', - ] - ) - self.client = Client() - self.client.force_login(user) - - vrf = VRF(name='VRF 1', rd='65000:1') - vrf.save() - - IPAddress.objects.bulk_create([ - IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf), - IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrf), - IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf), - ]) - - def test_ipaddress_list(self): - - url = reverse('ipam:ipaddress_list') - params = { - "vrf": VRF.objects.first().rd, + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'vrf': vrfs[1].pk, + 'tenant': None, + 'status': PrefixStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'is_pool': False, + 'description': 'New description', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - def test_ipaddress(self): +class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IPAddress - ipaddress = IPAddress.objects.first() - response = self.client.get(ipaddress.get_absolute_url()) - self.assertEqual(response.status_code, 200) + @classmethod + def setUpTestData(cls): - def test_ipaddress_import(self): + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + ) - csv_data = ( + IPAddress.objects.bulk_create([ + IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]), + IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]), + IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), + ]) + + cls.form_data = { + 'vrf': vrfs[1].pk, + 'address': IPNetwork('192.0.2.99/24'), + 'tenant': None, + 'status': IPAddressStatusChoices.STATUS_RESERVED, + 'role': IPAddressRoleChoices.ROLE_ANYCAST, + 'interface': None, + 'nat_inside': None, + 'dns_name': 'example', + 'description': 'A new IP address', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.csv_data = ( "address,status", "192.0.2.4/24,Active", "192.0.2.5/24,Active", "192.0.2.6/24,Active", ) - response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(IPAddress.objects.count(), 6) + cls.bulk_edit_data = { + 'vrf': vrfs[1].pk, + 'tenant': None, + 'status': IPAddressStatusChoices.STATUS_RESERVED, + 'role': IPAddressRoleChoices.ROLE_ANYCAST, + 'dns_name': 'example', + 'description': 'New description', + } -class VLANGroupTestCase(TestCase): +class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = VLANGroup - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vlangroup', - 'ipam.add_vlangroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') VLANGroup.objects.bulk_create([ VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site), @@ -316,104 +258,96 @@ class VLANGroupTestCase(TestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site), ]) - def test_vlangroup_list(self): - - url = reverse('ipam:vlangroup_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'name': 'VLAN Group X', + 'slug': 'vlan-group-x', + 'site': site.pk, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_vlangroup_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "VLAN Group 4,vlan-group-4", "VLAN Group 5,vlan-group-5", "VLAN Group 6,vlan-group-6", ) - response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(VLANGroup.objects.count(), 6) +class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VLAN + @classmethod + def setUpTestData(cls): -class VLANTestCase(TestCase): - - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vlan', - 'ipam.add_vlan', - ] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.client = Client() - self.client.force_login(user) + Site.objects.bulk_create(sites) - vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') - vlangroup.save() + vlangroups = ( + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), + ) + VLANGroup.objects.bulk_create(vlangroups) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) + Role.objects.bulk_create(roles) VLAN.objects.bulk_create([ - VLAN(group=vlangroup, vid=101, name='VLAN101'), - VLAN(group=vlangroup, vid=102, name='VLAN102'), - VLAN(group=vlangroup, vid=103, name='VLAN103'), + VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]), + VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]), + VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), ]) - def test_vlan_list(self): - - url = reverse('ipam:vlan_list') - params = { - "group": VLANGroup.objects.first().slug, + cls.form_data = { + 'site': sites[1].pk, + 'group': vlangroups[1].pk, + 'vid': 999, + 'name': 'VLAN999', + 'tenant': None, + 'status': VLANStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'description': 'A new VLAN', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_vlan(self): - - vlan = VLAN.objects.first() - response = self.client.get(vlan.get_absolute_url()) - self.assertEqual(response.status_code, 200) - - def test_vlan_import(self): - - csv_data = ( + cls.csv_data = ( "vid,name,status", "104,VLAN104,Active", "105,VLAN105,Active", "106,VLAN106,Active", ) - response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(VLAN.objects.count(), 6) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'group': vlangroups[1].pk, + 'tenant': None, + 'status': VLANStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'description': 'New description', + } -class ServiceTestCase(TestCase): +class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Service - def setUp(self): - user = create_test_user(permissions=['ipam.view_service']) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_import_objects = None - site = Site(name='Site 1', slug='site-1') - site.save() + # TODO: Resolve URL for Service creation + test_create_object = None - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() + @classmethod + def setUpTestData(cls): - devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) Service.objects.bulk_create([ Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), @@ -421,18 +355,19 @@ class ServiceTestCase(TestCase): Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), ]) - def test_service_list(self): - - url = reverse('ipam:service_list') - params = { - "device_id": Device.objects.first(), + cls.form_data = { + 'device': device.pk, + 'virtual_machine': None, + 'name': 'Service X', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'port': 999, + 'ipaddresses': [], + 'description': 'A new service', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) - - def test_service(self): - - service = Service.objects.first() - response = self.client.get(service.get_absolute_url()) - self.assertEqual(response.status_code, 200) + cls.bulk_edit_data = { + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'port': 888, + 'description': 'New description', + } diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 2a1dcdf05..604287f24 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -8,97 +8,97 @@ app_name = 'ipam' urlpatterns = [ # VRFs - path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'), - path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), - path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), - path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), - path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - path(r'vrfs//', views.VRFView.as_view(), name='vrf'), - path(r'vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), - path(r'vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), - path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), + path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), + path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), + path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), + path('vrfs//', views.VRFView.as_view(), name='vrf'), + path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), + path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), + path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs - path(r'rirs/', views.RIRListView.as_view(), name='rir_list'), - path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), - path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), - path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - path(r'rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), - path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs/', views.RIRListView.as_view(), name='rir_list'), + path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates - path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), - path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), - path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), - path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), - path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - path(r'aggregates//', views.AggregateView.as_view(), name='aggregate'), - path(r'aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), - path(r'aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - path(r'aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), + path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), + path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), + path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), + path('aggregates//', views.AggregateView.as_view(), name='aggregate'), + path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), + path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles - path(r'roles/', views.RoleListView.as_view(), name='role_list'), - path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'), - path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), - path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - path(r'roles//edit/', views.RoleEditView.as_view(), name='role_edit'), - path(r'roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles/', views.RoleListView.as_view(), name='role_list'), + path('roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes - path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'), - path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), - path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), - path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), - path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - path(r'prefixes//', views.PrefixView.as_view(), name='prefix'), - path(r'prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), - path(r'prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path(r'prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - path(r'prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - path(r'prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), + path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), + path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), + path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), + path('prefixes//', views.PrefixView.as_view(), name='prefix'), + path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), + path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), + path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), + path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), # IP addresses - path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), - path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), - path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), - path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), - path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), - path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - path(r'ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), - path(r'ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), - path(r'ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - path(r'ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), + path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), + path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), + path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), + path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + path('ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), + path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), + path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), + path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups - path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), - path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), - path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), - path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), - path(r'vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), - path(r'vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), - path(r'vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), + path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), + path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs - path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'), - path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), - path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), - path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), - path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - path(r'vlans//', views.VLANView.as_view(), name='vlan'), - path(r'vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), - path(r'vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), - path(r'vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), - path(r'vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path('vlans/', views.VLANListView.as_view(), name='vlan_list'), + path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), + path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), + path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), + path('vlans//', views.VLANView.as_view(), name='vlan'), + path('vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), + path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), + path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services - path(r'services/', views.ServiceListView.as_view(), name='service_list'), - path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), - path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), - path(r'services//', views.ServiceView.as_view(), name='service'), - path(r'services//edit/', views.ServiceEditView.as_view(), name='service_edit'), - path(r'services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), - path(r'services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path('services/', views.ServiceListView.as_view(), name='service_list'), + path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + path('services//', views.ServiceView.as_view(), name='service'), + path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), + path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), + path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c8c7d40ca..053098f0b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VRFFilterSet filterset_form = forms.VRFFilterForm table = tables.VRFTable - template_name = 'ipam/vrf_list.html' class VRFView(PermissionRequiredMixin, View): @@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView): queryset = Aggregate.objects.prefetch_related('rir').annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - filterset = filters.AggregateFilterSet filterset_form = forms.AggregateFilterForm table = tables.AggregateDetailTable @@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'ipam.view_role' queryset = Role.objects.all() table = tables.RoleTable - template_name = 'ipam/role_list.html' class RoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView): filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm table = tables.IPAddressDetailTable - template_name = 'ipam/ipaddress_list.html' class IPAddressView(PermissionRequiredMixin, View): @@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable - template_name = 'ipam/vlangroup_list.html' class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANFilterSet filterset_form = forms.VLANFilterForm table = tables.VLANDetailTable - template_name = 'ipam/vlan_list.html' class VLANView(PermissionRequiredMixin, View): @@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable - template_name = 'ipam/service_list.html' + action_buttons = ('export',) class ServiceView(PermissionRequiredMixin, View): diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index c1258d83b..7002def9b 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -10,7 +10,8 @@ # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] ALLOWED_HOSTS = [] -# PostgreSQL database configuration. +# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: +# https://docs.djangoproject.com/en/stable/ref/settings/#databases DATABASE = { 'NAME': 'netbox', # Database name 'USER': '', # PostgreSQL username @@ -27,6 +28,9 @@ REDIS = { 'webhooks': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, @@ -35,6 +39,9 @@ REDIS = { 'caching': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, 'DEFAULT_TIMEOUT': 300, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c9e62100c..bdd83723d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -74,6 +74,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) +DEVELOPER = getattr(configuration, 'DEVELOPER', False) EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) @@ -169,18 +170,31 @@ if 'caching' not in REDIS: WEBHOOKS_REDIS = REDIS.get('webhooks', {}) WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) +WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', []) +WEBHOOKS_REDIS_USING_SENTINEL = all([ + isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)), + len(WEBHOOKS_REDIS_SENTINELS) > 0 +]) +WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default') WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) + CACHING_REDIS = REDIS.get('caching', {}) -CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') -CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) -CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') -CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) -CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) -CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) +CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') +CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) +CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', []) +CACHING_REDIS_USING_SENTINEL = all([ + isinstance(CACHING_REDIS_SENTINELS, (list, tuple)), + len(CACHING_REDIS_SENTINELS) > 0 +]) +CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') +CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') +CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) +CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) +CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) # @@ -393,28 +407,35 @@ if LDAP_CONFIG is not None: # # Caching # - -if CACHING_REDIS_SSL: - REDIS_CACHE_CON_STRING = 'rediss://' +if CACHING_REDIS_USING_SENTINEL: + CACHEOPS_SENTINEL = { + 'locations': CACHING_REDIS_SENTINELS, + 'service_name': CACHING_REDIS_SENTINEL_SERVICE, + 'db': CACHING_REDIS_DATABASE, + } else: - REDIS_CACHE_CON_STRING = 'redis://' + if CACHING_REDIS_SSL: + REDIS_CACHE_CON_STRING = 'rediss://' + else: + REDIS_CACHE_CON_STRING = 'redis://' -if CACHING_REDIS_PASSWORD: - REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) + if CACHING_REDIS_PASSWORD: + REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) -REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( - REDIS_CACHE_CON_STRING, - CACHING_REDIS_HOST, - CACHING_REDIS_PORT, - CACHING_REDIS_DATABASE -) + REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( + REDIS_CACHE_CON_STRING, + CACHING_REDIS_HOST, + CACHING_REDIS_PORT, + CACHING_REDIS_DATABASE + ) + CACHEOPS_REDIS = REDIS_CACHE_CON_STRING if not CACHE_TIMEOUT: CACHEOPS_ENABLED = False else: CACHEOPS_ENABLED = True -CACHEOPS_REDIS = REDIS_CACHE_CON_STRING + CACHEOPS_DEFAULTS = { 'timeout': CACHE_TIMEOUT } @@ -533,6 +554,15 @@ RQ_QUEUES = { 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT, 'SSL': WEBHOOKS_REDIS_SSL, + } if not WEBHOOKS_REDIS_USING_SENTINEL else { + 'SENTINELS': WEBHOOKS_REDIS_SENTINELS, + 'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE, + 'DB': WEBHOOKS_REDIS_DATABASE, + 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, + 'SOCKET_TIMEOUT': None, + 'CONNECTION_KWARGS': { + 'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT + }, } } diff --git a/netbox/netbox/tests/test_views.py b/netbox/netbox/tests/test_views.py index db84dcd1a..1942471b0 100644 --- a/netbox/netbox/tests/test_views.py +++ b/netbox/netbox/tests/test_views.py @@ -1,6 +1,6 @@ import urllib.parse -from django.test import TestCase +from utilities.testing import TestCase from django.urls import reverse @@ -11,7 +11,7 @@ class HomeViewTestCase(TestCase): url = reverse('home') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_search(self): @@ -21,4 +21,4 @@ class HomeViewTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 66ab982eb..2c4d504b2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -26,49 +26,49 @@ schema_view = get_schema_view( _patterns = [ # Base views - path(r'', HomeView.as_view(), name='home'), - path(r'search/', SearchView.as_view(), name='search'), + path('', HomeView.as_view(), name='home'), + path('search/', SearchView.as_view(), name='search'), # Login/logout - path(r'login/', LoginView.as_view(), name='login'), - path(r'logout/', LogoutView.as_view(), name='logout'), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), # Apps - path(r'circuits/', include('circuits.urls')), - path(r'dcim/', include('dcim.urls')), - path(r'extras/', include('extras.urls')), - path(r'ipam/', include('ipam.urls')), - path(r'secrets/', include('secrets.urls')), - path(r'tenancy/', include('tenancy.urls')), - path(r'user/', include('users.urls')), - path(r'virtualization/', include('virtualization.urls')), + path('circuits/', include('circuits.urls')), + path('dcim/', include('dcim.urls')), + path('extras/', include('extras.urls')), + path('ipam/', include('ipam.urls')), + path('secrets/', include('secrets.urls')), + path('tenancy/', include('tenancy.urls')), + path('user/', include('users.urls')), + path('virtualization/', include('virtualization.urls')), # API - path(r'api/', APIRootView.as_view(), name='api-root'), - path(r'api/circuits/', include('circuits.api.urls')), - path(r'api/dcim/', include('dcim.api.urls')), - path(r'api/extras/', include('extras.api.urls')), - path(r'api/ipam/', include('ipam.api.urls')), - path(r'api/secrets/', include('secrets.api.urls')), - path(r'api/tenancy/', include('tenancy.api.urls')), - path(r'api/virtualization/', include('virtualization.api.urls')), - path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'), - path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), + path('api/', APIRootView.as_view(), name='api-root'), + path('api/circuits/', include('circuits.api.urls')), + path('api/dcim/', include('dcim.api.urls')), + path('api/extras/', include('extras.api.urls')), + path('api/ipam/', include('ipam.api.urls')), + path('api/secrets/', include('secrets.api.urls')), + path('api/tenancy/', include('tenancy.api.urls')), + path('api/virtualization/', include('virtualization.api.urls')), + path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), + path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware - path(r'media/', serve, {'document_root': settings.MEDIA_ROOT}), + path('media/', serve, {'document_root': settings.MEDIA_ROOT}), # Admin - path(r'admin/', admin_site.urls), - path(r'admin/webhook-backend-status/', include('django_rq.urls')), + path('admin/', admin_site.urls), + path('admin/webhook-backend-status/', include('django_rq.urls')), ] if settings.DEBUG: import debug_toolbar _patterns += [ - path(r'__debug__/', include(debug_toolbar.urls)), + path('__debug__/', include(debug_toolbar.urls)), ] if settings.METRICS_ENABLED: @@ -78,7 +78,7 @@ if settings.METRICS_ENABLED: # Prepend BASE_PATH urlpatterns = [ - path(r'{}'.format(settings.BASE_PATH), include(_patterns)) + path('{}'.format(settings.BASE_PATH), include(_patterns)) ] handler500 = 'utilities.views.server_error' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index fbe70300b..904dc7375 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -252,7 +252,7 @@ class HomeView(View): 'search_form': SearchForm(), 'stats': stats, 'report_results': ReportResult.objects.order_by('-created')[:10], - 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50] + 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15] }) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 45babe70b..456eeab6f 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -62,8 +62,20 @@ footer p { } } +/* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */ +@media (min-width: 768px) { + .navbar-nav>li>ul { + max-height: calc(80vh - 50px); + overflow-y: auto; + } +} + /* Collapse the nav menu on displays less than 980px wide */ @media (max-width: 979px) { + #navbar { + max-height: calc(80vh - 50px); + overflow-y: auto; + } .navbar-header { float: none; } diff --git a/netbox/project-static/css/rack_elevation.css b/netbox/project-static/css/rack_elevation.css index 06120c223..cbb5015a5 100644 --- a/netbox/project-static/css/rack_elevation.css +++ b/netbox/project-static/css/rack_elevation.css @@ -56,3 +56,12 @@ text { .blocked:hover+.add-device { fill: none; } + +.unit { + margin: 0; + padding: 5px 0px; + + fill: #c0c0c0; + font-size: 10px; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; +} diff --git a/netbox/project-static/js/configcontext.js b/netbox/project-static/js/configcontext.js new file mode 100644 index 000000000..1d731e696 --- /dev/null +++ b/netbox/project-static/js/configcontext.js @@ -0,0 +1,11 @@ +$('.rendered-context-format').on('click', function() { + if (!$(this).hasClass('active')) { + // Update selection in the button group + $('span.rendered-context-format').removeClass('active'); + $('span.rendered-context-format[data-format=' + $(this).data('format') + ']').addClass('active'); + + // Hide all rendered contexts and only show the selected one + $('div.rendered-context-data').hide(); + $('div.rendered-context-data[data-format=' + $(this).data('format') + ']').show(); + } +}); diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b1ba8a37c..802d1b4e9 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -190,15 +190,18 @@ $(document).ready(function() { $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-additional-query-param-")){ var param_name = attr.name.split("data-additional-query-param-")[1]; - if (param_name in parameters) { - if (Array.isArray(parameters[param_name])) { - parameters[param_name].push(attr.value) + + $.each($.parseJSON(attr.value), function(index, value) { + if (param_name in parameters) { + if (Array.isArray(parameters[param_name])) { + parameters[param_name].push(value); + } else { + parameters[param_name] = [parameters[param_name], value]; + } } else { - parameters[param_name] = [parameters[param_name], attr.value] + parameters[param_name] = value; } - } else { - parameters[param_name] = attr.value; - } + }); } }); @@ -220,19 +223,19 @@ $(document).ready(function() { } if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] } + results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; results[record.site.name + ":" + record.group.name].children.push(record); } else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] } + results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; results[record.group.name].children.push(record); } else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] } + results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; results[record.site.name].children.push(record); } else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] } + results['global'] = results['global'] || { text: 'Global', children: [] }; results['global'].children.push(record); } else { @@ -246,10 +249,9 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0]; results.unshift({ - id: null_option.value, - text: null_option.text + id: 'null', + text: 'None' }); } diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index def87b3a1..70abcfe29 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -15,15 +15,15 @@ router = routers.DefaultRouter() router.APIRootView = SecretsRootView # Field choices -router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') # Secrets -router.register(r'secret-roles', views.SecretRoleViewSet) -router.register(r'secrets', views.SecretViewSet) +router.register('secret-roles', views.SecretRoleViewSet) +router.register('secrets', views.SecretViewSet) # Miscellaneous -router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') -router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') +router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') +router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') app_name = 'secrets-api' urlpatterns = router.urls diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 873679775..367dc9bd0 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet): secret = self.get_object() - # Attempt to decrypt the secret if the master key is known - if self.master_key is not None: + # Attempt to decrypt the secret if the user is permitted and the master key is known + if secret.decryptable_by(request.user) and self.master_key is not None: secret.decrypt(self.master_key) serializer = self.get_serializer(secret) @@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet): if self.master_key is not None: secrets = [] for secret in page: - secret.decrypt(self.master_key) + # Enforce role permissions + if secret.decryptable_by(request.user): + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 3b81f9586..79064e0dd 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,10 +4,12 @@ from django import forms from taggit.forms import TagField from dcim.models import Device -from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, +) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, - StaticSelect2Multiple + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -68,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm): # Secrets # -class SecretForm(BootstrapMixin, CustomFieldForm): +class SecretForm(BootstrapMixin, CustomFieldModelForm): plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, @@ -85,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + role = DynamicModelChoiceField( + queryset=SecretRole.objects.all(), + widget=APISelect( + api_url="/api/secrets/secret-roles/" + ) + ) tags = TagField( required=False ) @@ -94,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldForm): fields = [ 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] - widgets = { - 'role': APISelect( - api_url="/api/secrets/secret-roles/" - ) - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -116,7 +119,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): }) -class SecretCSVForm(forms.ModelForm): +class SecretCSVForm(CustomFieldModelCSVForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -155,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=SecretRole.objects.all(), required=False, widget=APISelect( @@ -179,14 +182,16 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', + required=True, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", ) ) + tag = TagFilterField(model) # diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index cabc340f9..df32ad7f2 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,7 +5,8 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import APITestCase +from users.models import Token +from utilities.testing import APITestCase, create_test_user from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -131,7 +132,15 @@ class SecretTest(APITestCase): def setUp(self): - super().setUp() + # Create a non-superuser test user + self.user = create_test_user('testuser', permissions=( + 'secrets.add_secret', + 'secrets.change_secret', + 'secrets.delete_secret', + 'secrets.view_secret', + )) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -144,11 +153,11 @@ class SecretTest(APITestCase): 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), } - self.plaintext = { - 'secret1': 'Secret #1 Plaintext', - 'secret2': 'Secret #2 Plaintext', - 'secret3': 'Secret #3 Plaintext', - } + self.plaintexts = ( + 'Secret #1 Plaintext', + 'Secret #2 Plaintext', + 'Secret #3 Plaintext', + ) site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -160,17 +169,17 @@ class SecretTest(APITestCase): self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') self.secret1 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] + device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0] ) self.secret1.encrypt(self.master_key) self.secret1.save() self.secret2 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] + device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1] ) self.secret2.encrypt(self.master_key) self.secret2.save() self.secret3 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] + device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2] ) self.secret3.encrypt(self.master_key) self.secret3.save() @@ -178,16 +187,32 @@ class SecretTest(APITestCase): def test_get_secret(self): url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.get(url, **self.header) - self.assertEqual(response.data['plaintext'], self.plaintext['secret1']) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) + self.assertIsNone(response.data['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['plaintext'], self.plaintexts[0]) def test_list_secrets(self): url = reverse('secrets-api:secret-list') - response = self.client.get(url, **self.header) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + for secret in response.data['results']: + self.assertIsNone(secret['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], 3) + for i, secret in enumerate(response.data['results']): + self.assertEqual(secret['plaintext'], self.plaintexts[i]) def test_create_secret(self): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 43ae10dc6..96439a10d 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,26 +1,18 @@ import base64 -import urllib.parse -from django.test import Client, TestCase from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import create_test_user +from utilities.testing import ViewTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY -class SecretRoleTestCase(TestCase): +class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = SecretRole - def setUp(self): - user = create_test_user( - permissions=[ - 'secrets.view_secretrole', - 'secrets.add_secretrole', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): SecretRole.objects.bulk_create([ SecretRole(name='Secret Role 1', slug='secret-role-1'), @@ -28,89 +20,83 @@ class SecretRoleTestCase(TestCase): SecretRole(name='Secret Role 3', slug='secret-role-3'), ]) - def test_secretrole_list(self): + cls.form_data = { + 'name': 'Secret Role X', + 'slug': 'secret-role-x', + 'description': 'A secret role', + 'users': [], + 'groups': [], + } - url = reverse('secrets:secretrole_list') - - response = self.client.get(url, follow=True) - self.assertEqual(response.status_code, 200) - - def test_secretrole_import(self): - - csv_data = ( + cls.csv_data = ( "name,slug", "Secret Role 4,secret-role-4", "Secret Role 5,secret-role-5", "Secret Role 6,secret-role-6", ) - response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - self.assertEqual(SecretRole.objects.count(), 6) +class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Secret + # Disable inapplicable tests + test_create_object = None -class SecretTestCase(TestCase): + # TODO: Check permissions enforcement on secrets.views.secret_edit + test_edit_object = None + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + ) + Device.objects.bulk_create(devices) + + secretroles = ( + SecretRole(name='Secret Role 1', slug='secret-role-1'), + SecretRole(name='Secret Role 2', slug='secret-role-2'), + ) + SecretRole.objects.bulk_create(secretroles) + + # Create one secret per device to allow bulk-editing of names (which must be unique per device/role) + Secret.objects.bulk_create(( + Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), + Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), + Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), + )) + + cls.form_data = { + 'device': devices[1].pk, + 'role': secretroles[1].pk, + 'name': 'Secret X', + } + + cls.bulk_edit_data = { + 'role': secretroles[1].pk, + 'name': 'New name', + } def setUp(self): - user = create_test_user( - permissions=[ - 'secrets.view_secret', - 'secrets.add_secret', - ] - ) - # Set up a master key - userkey = UserKey(user=user, public_key=PUBLIC_KEY) + super().setUp() + + # Set up a master key for the test user + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() master_key = userkey.get_master_key(PRIVATE_KEY) self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) - self.client = Client() - self.client.force_login(user) - - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() - - secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1') - secretrole.save() - - Secret.objects.bulk_create([ - Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'), - Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'), - Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'), - ]) - - def test_secret_list(self): - - url = reverse('secrets:secret_list') - params = { - "role": SecretRole.objects.first().slug, - } - - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) - self.assertEqual(response.status_code, 200) - - def test_secret(self): - - secret = Secret.objects.first() - response = self.client.get(secret.get_absolute_url(), follow=True) - self.assertEqual(response.status_code, 200) - - def test_secret_import(self): + def test_import_objects(self): + self.add_permissions('secrets.add_secret') csv_data = ( "device,role,name,plaintext", @@ -125,5 +111,5 @@ class SecretTestCase(TestCase): response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Secret.objects.count(), 6) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 9d07dd63c..4ed08da7f 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -8,21 +8,21 @@ app_name = 'secrets' urlpatterns = [ # Secret roles - path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), - path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), - path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - path(r'secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - path(r'secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), + path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets - path(r'secrets/', views.SecretListView.as_view(), name='secret_list'), - path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), - path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), - path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), - path(r'secrets//', views.SecretView.as_view(), name='secret'), - path(r'secrets//edit/', views.secret_edit, name='secret_edit'), - path(r'secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), - path(r'secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path('secrets/', views.SecretListView.as_view(), name='secret_list'), + path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), + path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), + path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), + path('secrets//', views.SecretView.as_view(), name='secret'), + path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), + path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 288edaa6f..d92e4b64d 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'secrets.view_secretrole' queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable - template_name = 'secrets/secretrole_list.html' class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm table = tables.SecretTable - template_name = 'secrets/secret_list.html' + action_buttons = ('import', 'export') class SecretView(PermissionRequiredMixin, View): diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html deleted file mode 100644 index d686bdf7a..000000000 --- a/netbox/templates/circuits/circuit_list.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_circuit %} - {% add_button 'circuits:circuit_add' %} - {% import_button 'circuits:circuit_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Circuits{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} - {% include 'inc/tags_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html deleted file mode 100644 index 654d4ab09..000000000 --- a/netbox/templates/circuits/circuittype_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_circuittype %} - {% add_button 'circuits:circuittype_add' %} - {% import_button 'circuits:circuittype_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Circuit Types{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html deleted file mode 100644 index e4ee7fb2b..000000000 --- a/netbox/templates/circuits/provider_list.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_provider %} - {% add_button 'circuits:provider_add' %} - {% import_button 'circuits:provider_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Providers{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} - {% include 'inc/tags_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/cable_list.html b/netbox/templates/dcim/cable_list.html deleted file mode 100644 index 0dd8095a5..000000000 --- a/netbox/templates/dcim/cable_list.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_cable %} - {% import_button 'dcim:cable_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Cables{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index fa37f1ac5..5ede19d78 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -48,14 +48,30 @@ Add Components {% endif %} @@ -333,12 +349,12 @@ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}