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/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index bc79e90ab..8cadddeb5 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: @@ -127,7 +135,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- -# ENFORCE_GLOBAL_UNIQUE +## ENFORCE_GLOBAL_UNIQUE Default: False 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/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/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 7611ffce1..162523e0f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,10 +1,22 @@ # v2.7.4 (FUTURE) +## Enhancements + +* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV +* [#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 ## Enhancements diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bb82ac602..caf8d9d36 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,7 +2,9 @@ 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 ( @@ -17,7 +19,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 +48,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderCSVForm(forms.ModelForm): +class ProviderCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -161,7 +163,7 @@ class CircuitTypeCSVForm(forms.ModelForm): # Circuits # -class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): comments = CommentField() tags = TagField( required=False @@ -189,7 +191,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class CircuitCSVForm(forms.ModelForm): +class CircuitCSVForm(CustomFieldModelCSVForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 576437ef1..d2cb8e5ab 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 StandardTestCases -class ProviderTestCase(TestCase): +class ProviderTestCase(StandardTestCases.Views): + 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,45 @@ 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(StandardTestCases.Views): + model = CircuitType - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_circuittype', - 'circuits.add_circuittype', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): CircuitType.objects.bulk_create([ CircuitType(name='Circuit Type 1', slug='circuit-type-1'), @@ -74,79 +63,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(StandardTestCases.Views): + 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/dcim/forms.py b/netbox/dcim/forms.py index 3a11c8519..0320eefce 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 @@ -216,7 +217,7 @@ class RegionFilterForm(BootstrapMixin, forms.Form): # Sites # -class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, @@ -264,7 +265,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteCSVForm(forms.ModelForm): +class SiteCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, @@ -461,7 +462,7 @@ class RackRoleCSVForm(forms.ModelForm): # Racks # -class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): group = ChainedModelChoiceField( queryset=RackGroup.objects.all(), chains=( @@ -506,7 +507,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class RackCSVForm(forms.ModelForm): +class RackCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -900,7 +901,7 @@ class ManufacturerCSVForm(forms.ModelForm): # Device types # -class DeviceTypeForm(BootstrapMixin, CustomFieldForm): +class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField( slug_source='model' ) @@ -1520,7 +1521,7 @@ class PlatformCSVForm(forms.ModelForm): # Devices # -class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=APISelect( @@ -1552,6 +1553,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", filter_for={ @@ -1728,7 +1730,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', @@ -2736,7 +2738,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: @@ -4256,7 +4258,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # Power feeds # -class PowerFeedForm(BootstrapMixin, CustomFieldForm): +class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): site = ChainedModelChoiceField( queryset=Site.objects.all(), required=False, @@ -4301,7 +4303,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', @@ -4384,7 +4386,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - powerpanel = forms.ModelChoiceField( + power_panel = forms.ModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 350330757..2a4988ba4 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1018,9 +1018,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', ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 68bab8037..e37569f79 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -676,7 +676,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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 856862a3e..32789accd 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,116 +1,119 @@ 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 dcim.choices import * from dcim.constants import * from dcim.models import * -from utilities.testing import create_test_user +from utilities.testing import StandardTestCases, TestCase -class RegionTestCase(TestCase): +class RegionTestCase(StandardTestCases.Views): + model = Region - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_region', - 'dcim.add_region', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @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(StandardTestCases.Views): + 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(StandardTestCases.Views): + model = RackGroup - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rackgroup', - 'dcim.add_rackgroup', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -121,39 +124,30 @@ 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(StandardTestCases.Views): + model = RackRole + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None -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 +155,149 @@ 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(StandardTestCases.Views): + 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(StandardTestCases.Views): + 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(StandardTestCases.Views): + model = Manufacturer - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_manufacturer', - 'dcim.add_manufacturer', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): Manufacturer.objects.bulk_create([ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -280,73 +305,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(StandardTestCases.Views): + 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 +431,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 +443,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 +497,29 @@ 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', - ] - ) - self.client = Client() - self.client.force_login(user) + 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') + + +class DeviceRoleTestCase(StandardTestCases.Views): + model = DeviceRole + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): DeviceRole.objects.bulk_create([ DeviceRole(name='Device Role 1', slug='device-role-1'), @@ -506,141 +527,150 @@ 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(StandardTestCases.Views): + model = Platform + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None -class PlatformTestCase(TestCase): + @classmethod + def setUpTestData(cls): - 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(StandardTestCases.Views): + 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, + } +# TODO: Convert to StandardTestCases.Views class ConsolePortTestCase(TestCase): + user_permissions = ( + 'dcim.view_consoleport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleport', - 'dcim.add_consoleport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -668,9 +698,10 @@ class ConsolePortTestCase(TestCase): url = reverse('dcim:consoleport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_consoleport_import(self): + self.add_permissions('dcim.add_consoleport') csv_data = ( "device,name", @@ -681,21 +712,18 @@ class ConsolePortTestCase(TestCase): response = self.client.post(reverse('dcim:consoleport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ConsolePort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class ConsoleServerPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_consoleserverport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleserverport', - 'dcim.add_consoleserverport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -723,9 +751,10 @@ class ConsoleServerPortTestCase(TestCase): url = reverse('dcim:consoleserverport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_consoleserverport_import(self): + self.add_permissions('dcim.add_consoleserverport') csv_data = ( "device,name", @@ -736,21 +765,18 @@ class ConsoleServerPortTestCase(TestCase): response = self.client.post(reverse('dcim:consoleserverport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ConsoleServerPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class PowerPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_powerport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_powerport', - 'dcim.add_powerport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -778,9 +804,10 @@ class PowerPortTestCase(TestCase): url = reverse('dcim:powerport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_powerport_import(self): + self.add_permissions('dcim.add_powerport') csv_data = ( "device,name", @@ -791,21 +818,18 @@ class PowerPortTestCase(TestCase): response = self.client.post(reverse('dcim:powerport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(PowerPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class PowerOutletTestCase(TestCase): + user_permissions = ( + 'dcim.view_poweroutlet', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_poweroutlet', - 'dcim.add_poweroutlet', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -833,9 +857,10 @@ class PowerOutletTestCase(TestCase): url = reverse('dcim:poweroutlet_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_poweroutlet_import(self): + self.add_permissions('dcim.add_poweroutlet') csv_data = ( "device,name", @@ -846,21 +871,18 @@ class PowerOutletTestCase(TestCase): response = self.client.post(reverse('dcim:poweroutlet_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(PowerOutlet.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class InterfaceTestCase(TestCase): + user_permissions = ( + 'dcim.view_interface', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_interface', - 'dcim.add_interface', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -888,9 +910,10 @@ class InterfaceTestCase(TestCase): url = reverse('dcim:interface_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_interface_import(self): + self.add_permissions('dcim.add_interface') csv_data = ( "device,name,type", @@ -901,21 +924,18 @@ class InterfaceTestCase(TestCase): response = self.client.post(reverse('dcim:interface_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Interface.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class FrontPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_frontport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_frontport', - 'dcim.add_frontport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -955,9 +975,10 @@ class FrontPortTestCase(TestCase): url = reverse('dcim:frontport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_frontport_import(self): + self.add_permissions('dcim.add_frontport') csv_data = ( "device,name,type,rear_port,rear_port_position", @@ -968,21 +989,18 @@ class FrontPortTestCase(TestCase): response = self.client.post(reverse('dcim:frontport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(FrontPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class RearPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_rearport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rearport', - 'dcim.add_rearport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1010,9 +1028,10 @@ class RearPortTestCase(TestCase): url = reverse('dcim:rearport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rearport_import(self): + self.add_permissions('dcim.add_rearport') csv_data = ( "device,name,type,positions", @@ -1023,21 +1042,18 @@ class RearPortTestCase(TestCase): response = self.client.post(reverse('dcim:rearport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(RearPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class DeviceBayTestCase(TestCase): + user_permissions = ( + 'dcim.view_devicebay', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_devicebay', - 'dcim.add_devicebay', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1069,9 +1085,10 @@ class DeviceBayTestCase(TestCase): url = reverse('dcim:devicebay_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_devicebay_import(self): + self.add_permissions('dcim.add_devicebay') csv_data = ( "device,name", @@ -1082,21 +1099,18 @@ class DeviceBayTestCase(TestCase): response = self.client.post(reverse('dcim:devicebay_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(DeviceBay.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class InventoryItemTestCase(TestCase): + user_permissions = ( + 'dcim.view_inventoryitem', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_inventoryitem', - 'dcim.add_inventoryitem', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1127,9 +1141,10 @@ class InventoryItemTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_inventoryitem_import(self): + self.add_permissions('dcim.add_inventoryitem') csv_data = ( "device,name", @@ -1140,105 +1155,99 @@ class InventoryItemTestCase(TestCase): response = self.client.post(reverse('dcim:inventoryitem_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(InventoryItem.objects.count(), 6) -class CableTestCase(TestCase): +class CableTestCase(StandardTestCases.Views): + model = Cable - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_cable', - 'dcim.add_cable', - ] + # TODO: Creation URL needs termination context + test_create_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(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(StandardTestCases.Views): + 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 +1286,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(StandardTestCases.Views): + 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(StandardTestCases.Views): + 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..38e24308b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -60,7 +60,7 @@ urlpatterns = [ # 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/add/', views.RackCreateView.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'), @@ -318,7 +318,7 @@ urlpatterns = [ # 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/add/', views.PowerFeedCreateView.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'), 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/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..dd21b3d92 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,18 +1,16 @@ -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 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, FilterChoiceField, JSONField, SlugField, StaticSelect2, + BOOLEAN_WITH_BLANK_CHOICES, ) +from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -21,102 +19,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 +88,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 +110,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 +129,11 @@ 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: + self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False) # @@ -254,8 +204,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = [ - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', - 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', + 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ] widgets = { 'regions': APISelectMultiple( @@ -270,6 +220,12 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): 'platforms': APISelectMultiple( api_url="/api/dcim/platforms/" ), + 'cluster_groups': APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ), + 'clusters': APISelectMultiple( + api_url="/api/virtualization/clusters/" + ), 'tenant_groups': APISelectMultiple( api_url="/api/tenancy/tenant-groups/" ), @@ -340,6 +296,21 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): value_field="slug", ) ) + cluster_group = FilterChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/", + value_field="slug", + ) + ) + cluster_id = FilterChoiceField( + queryset=Cluster.objects.all(), + label='Cluster', + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/", + ) + ) tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', 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/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..ecb25a78c 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 StandardTestCases, TestCase -class TagTestCase(TestCase): +class TagTestCase(StandardTestCases.Views): + 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(StandardTestCases.Views): + 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/views.py b/netbox/extras/views.py index 2fce98cc4..73d29393f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -37,7 +37,8 @@ class TagListView(PermissionRequiredMixin, ObjectListView): template_name = 'extras/tag_list.html' -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): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 853a7bddd..24f044f79 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,7 +4,9 @@ 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 ( @@ -31,7 +33,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 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VRFCSVForm(forms.ModelForm): +class VRFCSVForm(CustomFieldModelCSVForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -145,7 +147,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # Aggregates # -class AggregateForm(BootstrapMixin, CustomFieldForm): +class AggregateForm(BootstrapMixin, CustomFieldModelForm): tags = TagField( required=False ) @@ -167,7 +169,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): } -class AggregateCSVForm(forms.ModelForm): +class AggregateCSVForm(CustomFieldModelCSVForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -265,7 +267,7 @@ class RoleCSVForm(forms.ModelForm): # Prefixes # -class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -343,7 +345,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', @@ -587,7 +589,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) # IP addresses # -class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): interface = forms.ModelChoiceField( queryset=Interface.objects.all(), required=False @@ -754,7 +756,7 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): ) -class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = IPAddress @@ -774,7 +776,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', @@ -1091,7 +1093,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1139,7 +1141,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VLANCSVForm(forms.ModelForm): +class VLANCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1315,7 +1317,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # Services # -class ServiceForm(BootstrapMixin, CustomFieldForm): +class ServiceForm(BootstrapMixin, CustomFieldModelForm): port = forms.IntegerField( min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX @@ -1396,5 +1398,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class Meta: nullable_fields = [ - 'site', 'tenant', 'role', 'description', + 'description', ] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 6f08f2d47..cfa06788c 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 StandardTestCases -class VRFTestCase(TestCase): +class VRFTestCase(StandardTestCases.Views): + 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,39 @@ 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(StandardTestCases.Views): + model = RIR - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_rir', - 'ipam.add_rir', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): RIR.objects.bulk_create([ RIR(name='RIR 1', slug='rir-1'), @@ -77,91 +60,71 @@ 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(StandardTestCases.Views): + 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(StandardTestCases.Views): + model = Role - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_role', - 'ipam.add_role', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): Role.objects.bulk_create([ Role(name='Role 1', slug='role-1'), @@ -169,146 +132,140 @@ 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(StandardTestCases.Views): + 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(StandardTestCases.Views): + 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(StandardTestCases.Views): + model = VLANGroup - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vlangroup', - 'ipam.add_vlangroup', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None - site = Site(name='Site 1', slug='site-1') - site.save() + @classmethod + def setUpTestData(cls): + + 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 +273,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(StandardTestCases.Views): + 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(StandardTestCases.Views): + 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 +370,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/netbox/settings.py b/netbox/netbox/settings.py index 8cdbb60a3..483c21121 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', []) 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/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/secrets/forms.py b/netbox/secrets/forms.py index 9c1acccdb..2b5e059ca 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,7 +4,9 @@ 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, TagFilterField @@ -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, @@ -116,7 +118,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): }) -class SecretCSVForm(forms.ModelForm): +class SecretCSVForm(CustomFieldModelCSVForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 43ae10dc6..94f4cbd6a 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,26 +1,23 @@ 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 StandardTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY -class SecretRoleTestCase(TestCase): +class SecretRoleTestCase(StandardTestCases.Views): + model = SecretRole - def setUp(self): - user = create_test_user( - permissions=[ - 'secrets.view_secretrole', - 'secrets.add_secretrole', - ] - ) - self.client = Client() - self.client.force_login(user) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): SecretRole.objects.bulk_create([ SecretRole(name='Secret Role 1', slug='secret-role-1'), @@ -28,89 +25,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(StandardTestCases.Views): + 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 +116,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/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 7cec3f403..7731acae5 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load helpers %} +{% load static %} {% block header %}