Compare commits

..

62 Commits

Author SHA1 Message Date
Jeremy Stretch
3f1b42d466 Release v2.5-beta2 2018-11-26 16:27:57 -05:00
Jeremy Stretch
7d8ae5e763 Fixes #2609: Fixed exception when ChoiceField integer value is passed as a string 2018-11-26 14:05:57 -05:00
Jeremy Stretch
2bae50f501 Enforce consistent ordering of value/label keys for ChoiceField 2018-11-26 14:02:38 -05:00
Jeremy Stretch
a46f68c6e4 Fixes #2608: Fixed null outer_unit error on rack import 2018-11-26 13:41:35 -05:00
Jeremy Stretch
d59be2912e Closes #2601: Added a description field to pass-through ports 2018-11-20 21:28:19 -05:00
Jeremy Stretch
240d22696f Strip annotations from queryset when retrieving object count for API pagination 2018-11-20 21:02:06 -05:00
Jeremy Stretch
2b1516ea79 Changelog for #2602 2018-11-20 13:21:38 -05:00
Jeremy Stretch
89622f1ddf Fixes #2602: Return HTTP 204 when no new IPs/prefixes are available for provisioning 2018-11-20 13:03:59 -05:00
Jeremy Stretch
874acab90f Merge v2.4.8 2018-11-20 12:28:24 -05:00
Jeremy Stretch
34bfb899d1 Post-release version bump 2018-11-20 11:58:19 -05:00
Jeremy Stretch
55c153c5a9 Release v2.4.8 2018-11-20 11:56:14 -05:00
Jeremy Stretch
c29ae9b785 Added missing import buttons to cables list 2018-11-20 09:41:38 -05:00
Jeremy Stretch
5ce955a719 Fixed toggling of connection status for cable path endpoints 2018-11-20 09:23:30 -05:00
Jeremy Stretch
8c3a294384 Standardized behavior and display of connection_status 2018-11-19 15:26:06 -05:00
Jeremy Stretch
55cc327e05 Updated migrations 2018-11-19 13:44:18 -05:00
Jeremy Stretch
a324638f1f Improved logic for recording cable path connection status 2018-11-19 12:37:53 -05:00
Jeremy Stretch
3366a6ae3d Closes #2557: Added object view for tags 2018-11-15 16:47:41 -05:00
Jeremy Stretch
7dde370ee1 Fixes #2593: Fix toggling of connected cable's status 2018-11-15 14:23:23 -05:00
John Anderson
dfe6ba5603 #2583 changelog 2018-11-15 00:55:47 -05:00
John Anderson
fd9b2f2fda cleanup component filters and UI filter forms for device and device type - #2583 2018-11-15 00:42:01 -05:00
John Anderson
3c0181ef35 added logic to append &type=physical to interface API query for cable creation - #2585 2018-11-14 23:35:15 -05:00
Jeremy Stretch
641254b23a Closes #2053: Introduced the LOGIN_TIMEOUT configuration setting 2018-11-14 14:18:32 -05:00
Jeremy Stretch
63bd48003e Clean up cable termination types 2018-11-14 12:17:18 -05:00
Jeremy Stretch
23cde65add Fixes #2589: Virtual machine API serializer should require cluster assignment 2018-11-14 10:38:53 -05:00
Jeremy Stretch
408f632636 Fixes #2588: Catch all exceptions from failed NAPALM API Calls 2018-11-14 10:12:35 -05:00
Jeremy Stretch
83be0b5db4 Closes #2490: Added bulk editing for config contexts 2018-11-13 15:08:55 -05:00
Jeremy Stretch
7bed48f5fe Expanded device interfaces display to include MTU, MAC address, and tags 2018-11-13 14:18:00 -05:00
Jeremy Stretch
2fce7ebd8f Fixes #2565: Improved rendering of Markdown tables 2018-11-13 11:02:48 -05:00
Jeremy Stretch
f8e6cfbeba Closes #2426: Introduced SESSION_FILE_PATH configuration setting for authentication without write access to database 2018-11-13 10:31:44 -05:00
John Anderson
fc41359df6 removed blank line 2018-11-12 23:34:14 -05:00
John Anderson
5649024d93 #2586 changelog 2018-11-12 23:27:14 -05:00
John Anderson
65bc8f0254 resolved #2586 - tests for the Cable model clean method 2018-11-12 23:26:08 -05:00
John Anderson
7887a70b70 #2585 changelog 2018-11-12 23:20:17 -05:00
John Anderson
8a6913fe19 fixed #2585 - connections with virtual interfaces 2018-11-12 23:18:23 -05:00
John Anderson
7cd0e0b244 #2584 changelog 2018-11-12 22:19:30 -05:00
John Anderson
9543b5e716 fixed #2584 - validation for connecting front port to corresponding rear port 2018-11-12 22:15:06 -05:00
Jeremy Stretch
bc8dbfde7c Merge branch 'develop' into develop-2.5 2018-11-12 16:03:52 -05:00
Jeremy Stretch
0c33af2140 Fixes #2558: Filter on all tags when multiple are passed 2018-11-12 15:48:58 -05:00
Jeremy Stretch
b6a256dc5d Expanded the development style guide 2018-11-12 14:36:09 -05:00
Jeremy Stretch
5785fb6ba2 Added development docs for extending a model 2018-11-12 13:59:58 -05:00
Jeremy Stretch
59589fdd29 Fixes #2580: Remove erroneous CSS class from "add components" button 2018-11-12 12:04:04 -05:00
Jeremy Stretch
75f0d8ee90 Closes #2578: Reorganized nested serializers 2018-11-12 11:36:44 -05:00
Jeremy Stretch
04ae6ec7af Fixes #2554: Fix cable trace display when following a rear port with no cable attached 2018-11-09 15:22:34 -05:00
Jeremy Stretch
3bbf4a3352 Fixes #2579: Add missing cable disconnect buttons for front/rear ports 2018-11-09 15:17:42 -05:00
Jeremy Stretch
0316072863 Fixes #2574: Remove duplicate interface links from topology maps 2018-11-09 09:54:30 -05:00
Jeremy Stretch
845d467fd9 Fixes #2575: Correct model specified for rack roles table 2018-11-09 09:46:30 -05:00
Jeremy Stretch
be5bf6b711 Closes #2570: Add bulk disconnect view for front/rear pass-through ports 2018-11-08 15:12:24 -05:00
Jeremy Stretch
788847edaa Fixes #2573: Fix bulk console/power/interface disconnections 2018-11-08 15:04:34 -05:00
Jeremy Stretch
61ca7ee7c2 Closes #2559: Add a pre-commit git hook to enforce PEP8 validation 2018-11-08 13:52:34 -05:00
Jeremy Stretch
30f8fb4c11 Fixes #2572: Add button to disconnect cable from circuit termination 2018-11-08 12:27:15 -05:00
Jeremy Stretch
3e92aa9fe7 Fixes #2571: Enforce deletion of attached cable when deleting a termination point 2018-11-08 12:15:56 -05:00
Jeremy Stretch
bb5432de7d PEP8 fix 2018-11-08 11:13:03 -05:00
Jeremy Stretch
21fd889810 Fix regression from #2566 2018-11-08 11:11:52 -05:00
Jeremy Stretch
a228f1e1c2 Closes #2569: Added LSH fiber type; removed SC duplex/simplex designations 2018-11-08 10:49:26 -05:00
Jeremy Stretch
4b5181d640 Fixes #2566: Prevent both ends of a cable from connecting to the same termination point 2018-11-08 10:40:17 -05:00
Jeremy Stretch
0dee55885b Fixes #2567: Introduced proxy models to represent console/power/interface connections 2018-11-08 09:51:47 -05:00
Jeremy Stretch
1e36a884fa Changelog for #2563 2018-11-08 09:11:28 -05:00
Jeremy Stretch
d4e266d48c Fixes #2563: Enable export templates for cables 2018-11-07 15:49:45 -05:00
Jeremy Stretch
69d829ce8d Fixes #2473: Fix encoding of long (>127 character) secrets 2018-11-07 13:44:16 -05:00
Jeremy Stretch
c1838104ae Add lag description to lag column 2018-11-07 12:20:14 -05:00
Jeremy Stretch
c716ca1e87 Changelog query optimization 2018-11-07 10:42:04 -05:00
mmahacek
c063961e4a Add Cable count to home page (#2556)
* Add stats.cable_count

* Add Cable count to home page
2018-11-07 10:36:04 -05:00
95 changed files with 2281 additions and 1328 deletions

View File

@@ -1,4 +1,4 @@
v2.5-beta1 (2018-11-06)
v2.5-beta2 (2018-11-26)
## BETA RELEASE
@@ -31,11 +31,37 @@ NetBox now supports modeling physical cables for console, power, and interface c
* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added an `asset_tag` field for racks
* [#1931](https://github.com/digitalocean/netbox/issues/1931) - Added a count of assigned IP addresses to the interface API serializer
* [#2000](https://github.com/digitalocean/netbox/issues/2000) - Dropped support for Python 2
* [#2053](https://github.com/digitalocean/netbox/issues/2053) - Introduced the `LOGIN_TIMEOUT` configuration setting
* [#2057](https://github.com/digitalocean/netbox/issues/2057) - Added description columns to interface connections list
* [#2104](https://github.com/digitalocean/netbox/issues/2104) - Added a `status` field for racks
* [#2165](https://github.com/digitalocean/netbox/issues/2165) - Improved natural ordering of Interfaces
* [#2292](https://github.com/digitalocean/netbox/issues/2292) - Removed the deprecated UserAction model
* [#2367](https://github.com/digitalocean/netbox/issues/2367) - Removed deprecated RPCClient functionality
* [#2426](https://github.com/digitalocean/netbox/issues/2426) - Introduced `SESSION_FILE_PATH` configuration setting for authentication without write access to database
## Changes From v2.5-beta1
* [#2554](https://github.com/digitalocean/netbox/issues/2554) - Fix cable trace display when following a rear port with no cable attached
* [#2563](https://github.com/digitalocean/netbox/issues/2563) - Enable export templates for cables
* [#2566](https://github.com/digitalocean/netbox/issues/2566) - Prevent both ends of a cable from connecting to the same termination point
* [#2567](https://github.com/digitalocean/netbox/issues/2567) - Introduced proxy models to represent console/power/interface connections
* [#2569](https://github.com/digitalocean/netbox/issues/2569) - Added LSH fiber type; removed SC duplex/simplex designations
* [#2570](https://github.com/digitalocean/netbox/issues/2570) - Add bulk disconnect view for front/rear pass-through ports
* [#2571](https://github.com/digitalocean/netbox/issues/2571) - Enforce deletion of attached cable when deleting a termination point
* [#2572](https://github.com/digitalocean/netbox/issues/2572) - Add button to disconnect cable from circuit termination
* [#2573](https://github.com/digitalocean/netbox/issues/2573) - Fix bulk console/power/interface disconnections
* [#2574](https://github.com/digitalocean/netbox/issues/2574) - Remove duplicate interface links from topology maps
* [#2578](https://github.com/digitalocean/netbox/issues/2578) - Reorganized nested serializers
* [#2579](https://github.com/digitalocean/netbox/issues/2579) - Add missing cable disconnect buttons for front/rear ports
* [#2583](https://github.com/digitalocean/netbox/issues/2583) - Cleaned up component filters for device and device type
* [#2584](https://github.com/digitalocean/netbox/issues/2584) - Prevent a Front port from being connected to its corresponding rear port
* [#2585](https://github.com/digitalocean/netbox/issues/2585) - Prevent cable connections that include a virtual interface
* [#2586](https://github.com/digitalocean/netbox/issues/2586) - Added tests for the Cable model's clean() method
* [#2593](https://github.com/digitalocean/netbox/issues/2593) - Fix toggling of connected cable's status
* [#2601](https://github.com/digitalocean/netbox/issues/2601) - Added a `description` field to pass-through ports
* [#2602](https://github.com/digitalocean/netbox/issues/2602) - Return HTTP 204 when no new IPs/prefixes are available for provisioning
* [#2608](https://github.com/digitalocean/netbox/issues/2608) - Fixed null `outer_unit` error on rack import
* [#2609](https://github.com/digitalocean/netbox/issues/2609) - Fixed exception when ChoiceField integer value is passed as a string
## API Changes
@@ -55,6 +81,25 @@ NetBox now supports modeling physical cables for console, power, and interface c
* The field `interface_ordering` has been removed from the DeviceType serializer
* Added a `description` field to the CircuitTermination serializer
* Added `ipaddress_count` to InterfaceSerializer to show the count of assigned IP addresses for each interface
* The `available-prefixes` and `available-ips` IPAM endpoints now return an HTTP 204 response instead of HTTP 400 when no new objects can be created
---
v2.4.8 (2018-11-20)
## Enhancements
* [#2490](https://github.com/digitalocean/netbox/issues/2490) - Added bulk editing for config contexts
* [#2557](https://github.com/digitalocean/netbox/issues/2557) - Added object view for tags
## Bug Fixes
* [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets
* [#2558](https://github.com/digitalocean/netbox/issues/2558) - Filter on all tags when multiple are passed
* [#2565](https://github.com/digitalocean/netbox/issues/2565) - Improved rendering of Markdown tables
* [#2575](https://github.com/digitalocean/netbox/issues/2575) - Correct model specified for rack roles table
* [#2588](https://github.com/digitalocean/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls
* [#2589](https://github.com/digitalocean/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment
---

View File

@@ -133,6 +133,14 @@ Setting this to True will permit only authenticated users to access any part of
---
## LOGIN_TIMEOUT
Default: 1209600 seconds (14 days)
The liftetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
---
## MAINTENANCE_MODE
Default: False
@@ -223,6 +231,14 @@ The file path to the location where custom reports will be kept. By default, thi
---
## SESSION_FILE_PATH
Default: None
Session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in the PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the user as which NetBox runs must have read and write permissions to this path.
---
## TIME_ZONE
Default: UTC

View File

@@ -0,0 +1,70 @@
# Extending Models
Below is a list of items to consider when adding a new field to a model:
### 1. Generate and run database migration
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
```
./manage.py makemigrations <app> -n <name>
./manage.py migrate
```
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
!!! note
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
### 2. Add validation logic to `clean()`
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
```
class Foo(models.Model):
def clean(self):
super(DeviceCSVForm, self).clean()
# Custom validation goes here
if self.bar is None:
raise ValidationError()
```
### 3. Add CSV helpers
Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format.
### 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
### 5. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
### 6. Add field to forms
Extend any forms to include the new field as appropriate. Common forms include:
* **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on mnay objects at once
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
### 7. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
### 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
### 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
### 10. Adjust API and model tests
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.

View File

@@ -28,10 +28,3 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters
## Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted:
* [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
* Constants may be imported via wildcard (for example, `from .constants import *`).

View File

@@ -0,0 +1,41 @@
# Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
## PEP 8 Exceptions
* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (`constants.py`)
* The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
* Maximum line length is 120 characters (E501)
* This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
* Line breaks are permitted following binary operators (W504)
## Enforcing Code Style
The `pycodestyle` utility (previously `pep8`) is used by the CI process to enforce code style. It is strongly recommended to include as part of your commit process. A git commit hook is provided in the source at `scripts/git-hooks/pre-commit`. Linking to this script from `.git/hooks/` will invoke `pycodestyle` prior to every commit attempt and abort if the validation fails.
```
$ cd .git/hooks/
$ ln -s ../../scripts/git-hooks/pre-commit
```
To invoke `pycodestyle` manually, run:
```
pycodestyle --ignore=W504,E501 netbox/
```
## General Guidance
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
* Every model should have a docstring. Every custom method should include an expalantion of its function.
* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.

View File

@@ -45,7 +45,9 @@ pages:
- Examples: 'api/examples.md'
- Development:
- Introduction: 'development/index.md'
- Style Guide: 'development/style-guide.md'
- Utility Views: 'development/utility-views.md'
- Extending Models: 'development/extending-models.md'
- Release Checklist: 'development/release-checklist.md'
markdown_extensions:

View File

@@ -0,0 +1,52 @@
from rest_framework import serializers
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',
'NestedCircuitTerminationSerializer',
'NestedCircuitTypeSerializer',
'NestedProviderSerializer',
]
#
# Providers
#
class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
class Meta:
model = Provider
fields = ['id', 'url', 'name', 'slug']
#
# Circuits
#
class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
class Meta:
model = CircuitType
fields = ['id', 'url', 'name', 'slug']
class NestedCircuitSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta:
model = Circuit
fields = ['id', 'url', 'cid']
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'circuit', 'term_side']

View File

@@ -1,12 +1,13 @@
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer
from .nested_serializers import *
#
@@ -24,16 +25,8 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
class Meta:
model = Provider
fields = ['id', 'url', 'name', 'slug']
#
# Circuit types
# Circuits
#
class CircuitTypeSerializer(ValidatedModelSerializer):
@@ -43,18 +36,6 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
class Meta:
model = CircuitType
fields = ['id', 'url', 'name', 'slug']
#
# Circuits
#
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer()
status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False)
@@ -70,36 +51,14 @@ class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedCircuitSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta:
model = Circuit
fields = ['id', 'url', 'cid']
#
# Circuit Terminations
#
class CircuitTerminationSerializer(ValidatedModelSerializer):
class CircuitTerminationSerializer(ConnectedEndpointSerializer):
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
connected_endpoint = NestedInterfaceSerializer(read_only=True)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'connected_endpoint', 'cable',
'description', 'connected_endpoint', 'connection_status', 'cable',
]
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'circuit', 'term_side']

View File

@@ -4,7 +4,7 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -29,9 +29,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Provider
@@ -110,9 +108,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Circuit

View File

@@ -70,7 +70,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuittermination',
name='connection_status',
field=models.NullBooleanField(default=True),
field=models.NullBooleanField(),
),
migrations.AddField(
model_name='circuittermination',

View File

@@ -237,7 +237,7 @@ class CircuitTermination(CableTermination):
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
blank=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)'

View File

@@ -0,0 +1,243 @@
from rest_framework import serializers
from dcim.models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate,
Region, Site, VirtualChassis,
)
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
'NestedConsolePortSerializer',
'NestedConsoleServerPortSerializer',
'NestedDeviceBaySerializer',
'NestedDeviceRoleSerializer',
'NestedDeviceSerializer',
'NestedDeviceTypeSerializer',
'NestedFrontPortSerializer',
'NestedFrontPortTemplateSerializer',
'NestedInterfaceSerializer',
'NestedManufacturerSerializer',
'NestedPlatformSerializer',
'NestedPowerOutletSerializer',
'NestedPowerPortSerializer',
'NestedRackGroupSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
'NestedRearPortSerializer',
'NestedRearPortTemplateSerializer',
'NestedRegionSerializer',
'NestedSiteSerializer',
'NestedVirtualChassisSerializer',
]
#
# Regions/sites
#
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
class Meta:
model = Region
fields = ['id', 'url', 'name', 'slug']
class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = Site
fields = ['id', 'url', 'name', 'slug']
#
# Racks
#
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
class Meta:
model = RackGroup
fields = ['id', 'url', 'name', 'slug']
class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
class Meta:
model = RackRole
fields = ['id', 'url', 'name', 'slug']
class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
class Meta:
model = Rack
fields = ['id', 'url', 'name', 'display_name']
#
# Device types
#
class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
class Meta:
model = Manufacturer
fields = ['id', 'url', 'name', 'slug']
class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
class Meta:
model = DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = RearPortTemplate
fields = ['id', 'url', 'name']
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta:
model = FrontPortTemplate
fields = ['id', 'url', 'name']
#
# Devices
#
class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
class Meta:
model = DeviceRole
fields = ['id', 'url', 'name', 'slug']
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
class Meta:
model = Platform
fields = ['id', 'url', 'name', 'slug']
class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta:
model = Device
fields = ['id', 'url', 'name', 'display_name']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedConsolePortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedPowerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = PowerPort
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedRearPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedFrontPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
class Meta:
model = FrontPort
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = DeviceBay
fields = ['id', 'url', 'device', 'name']
#
# Cables
#
class NestedCableSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = Cable
fields = ['id', 'url', 'label']
#
# Virtual chassis
#
class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'master']

View File

@@ -2,7 +2,6 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.models import Circuit, CircuitTermination
from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -11,28 +10,40 @@ from dcim.models import (
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import (
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer, get_serializer_for_model,
)
from virtualization.models import Cluster
from virtualization.api.nested_serializers import NestedClusterSerializer
from .nested_serializers import *
class ConnectedEndpointSerializer(ValidatedModelSerializer):
connected_endpoint = serializers.SerializerMethodField(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
def get_connected_endpoint(self, obj):
"""
Return the appropriate serializer for the type of connected object.
"""
if getattr(obj, 'connected_endpoint', None) is None:
return None
serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested')
context = {'request': self.context['request']}
data = serializer(obj.connected_endpoint, context=context).data
return data
#
# Regions
# Regions/sites
#
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
class Meta:
model = Region
fields = ['id', 'url', 'name', 'slug']
class RegionSerializer(serializers.ModelSerializer):
parent = NestedRegionSerializer(required=False, allow_null=True)
@@ -41,10 +52,6 @@ class RegionSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'parent']
#
# Sites
#
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
@@ -62,16 +69,8 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = Site
fields = ['id', 'url', 'name', 'slug']
#
# Rack groups
# Racks
#
class RackGroupSerializer(ValidatedModelSerializer):
@@ -82,18 +81,6 @@ class RackGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'site']
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
class Meta:
model = RackGroup
fields = ['id', 'url', 'name', 'slug']
#
# Rack roles
#
class RackRoleSerializer(ValidatedModelSerializer):
class Meta:
@@ -101,18 +88,6 @@ class RackRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color']
class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
class Meta:
model = RackRole
fields = ['id', 'url', 'name', 'slug']
#
# Racks
#
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
@@ -151,26 +126,6 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
return data
class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
class Meta:
model = Rack
fields = ['id', 'url', 'name', 'display_name']
#
# Rack units
#
class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta:
model = Device
fields = ['id', 'url', 'name', 'display_name']
class RackUnitSerializer(serializers.Serializer):
"""
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
@@ -181,10 +136,6 @@ class RackUnitSerializer(serializers.Serializer):
device = NestedDeviceSerializer(read_only=True)
#
# Rack reservations
#
class RackReservationSerializer(ValidatedModelSerializer):
rack = NestedRackSerializer()
user = NestedUserSerializer()
@@ -196,7 +147,7 @@ class RackReservationSerializer(ValidatedModelSerializer):
#
# Manufacturers
# Device types
#
class ManufacturerSerializer(ValidatedModelSerializer):
@@ -206,18 +157,6 @@ class ManufacturerSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
class Meta:
model = Manufacturer
fields = ['id', 'url', 'name', 'slug']
#
# Device types
#
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
@@ -232,19 +171,6 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
class Meta:
model = DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
#
# Console port templates
#
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
@@ -253,10 +179,6 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name']
#
# Console server port templates
#
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
@@ -265,10 +187,6 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name']
#
# Power port templates
#
class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
@@ -277,10 +195,6 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name']
#
# Power outlet templates
#
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
@@ -289,10 +203,6 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name']
#
# Interface templates
#
class InterfaceTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
@@ -302,10 +212,6 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
#
# Rear port templates
#
class RearPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
@@ -315,18 +221,6 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name', 'type', 'positions']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = RearPortTemplate
fields = ['id', 'url', 'name']
#
# Front port templates
#
class FrontPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
@@ -337,18 +231,6 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta:
model = FrontPortTemplate
fields = ['id', 'url', 'name']
#
# Device bay templates
#
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
@@ -358,7 +240,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
#
# Device roles
# Devices
#
class DeviceRoleSerializer(ValidatedModelSerializer):
@@ -368,18 +250,6 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'vm_role']
class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
class Meta:
model = DeviceRole
fields = ['id', 'url', 'name', 'slug']
#
# Platforms
#
class PlatformSerializer(ValidatedModelSerializer):
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
@@ -388,46 +258,6 @@ class PlatformSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
class Meta:
model = Platform
fields = ['id', 'url', 'name', 'slug']
#
# Devices
#
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class DeviceIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
fields = ['id', 'url', 'family', 'address']
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
# Cannot import NestedVirtualChassisSerializer due to circular dependency
class DeviceVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'master']
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
@@ -437,12 +267,12 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True)
status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False)
primary_ip = DeviceIPAddressSerializer(read_only=True)
primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
@@ -450,8 +280,8 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'local_context_data',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -486,14 +316,161 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated', 'local_context_data',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated',
]
def get_config_context(self, obj):
return obj.get_config_context()
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
serializer=NestedVLANSerializer,
required=False,
many=True
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()
def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site.
device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
"be global.".format(vlan)
})
return super(InterfaceSerializer, self).validate(data)
class RearPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = RearPort
fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags']
class FrontPortRearPortSerializer(WritableNestedSerializer):
"""
NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
"""
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
fields = ['id', 'url', 'name']
class FrontPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = FrontPort
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device', 'tags']
#
# Inventory items
#
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
fields = [
'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
]
#
# Cables
#
@@ -548,305 +525,6 @@ class TracedCableSerializer(serializers.ModelSerializer):
]
class NestedCableSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = Cable
fields = ['id', 'url', 'label']
#
# Console server ports
#
class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_endpoint', 'cable', 'tags']
read_only_fields = ['connected_endpoint']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Console ports
#
class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
connected_endpoint = NestedConsoleServerPortSerializer(read_only=True)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Power outlets
#
class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_endpoint', 'cable', 'tags']
read_only_fields = ['connected_endpoint']
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Power ports
#
class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
connected_endpoint = NestedPowerOutletSerializer(read_only=True)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = PowerPort
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Interfaces
#
class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name', 'cable']
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta:
model = Circuit
fields = ['id', 'url', 'cid']
class InterfaceCircuitTerminationSerializer(WritableNestedSerializer):
circuit = InterfaceNestedCircuitSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
]
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
class InterfaceVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
serializer=InterfaceVLANSerializer,
required=False,
many=True
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
]
def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site.
device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
"be global.".format(vlan)
})
return super(InterfaceSerializer, self).validate(data)
def get_connected_endpoint(self, obj):
"""
Return the appropriate serializer for the type of connected object.
"""
if obj.connected_endpoint is None:
return None
serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested')
context = {'request': self.context['request']}
data = serializer(obj.connected_endpoint, context=context).data
return data
#
# Rear ports
#
class RearPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = RearPort
fields = ['id', 'device', 'name', 'type', 'positions', 'cable', 'tags']
class NestedRearPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Front ports
#
class FrontPortRearPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
fields = ['id', 'url', 'name']
class FrontPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = FrontPort
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'cable', 'tags']
class NestedFrontPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
class Meta:
model = FrontPort
fields = ['id', 'url', 'device', 'name', 'cable']
#
# Device bays
#
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device', 'tags']
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = DeviceBay
fields = ['id', 'url', 'device', 'name']
#
# Inventory items
#
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
fields = [
'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
]
#
# Interface connections
#
@@ -876,11 +554,3 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain', 'tags']
class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
class Meta:
model = VirtualChassis
fields = ['id', 'url']

View File

@@ -309,9 +309,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
# Check that NAPALM is installed
try:
import napalm
from napalm.base.exceptions import ModuleImportError
except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
from napalm.base.exceptions import ModuleImportError
# Validate the configured driver
try:
@@ -355,7 +355,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
try:
response[method] = getattr(d, method)()
except NotImplementedError:
response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)}
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:
response[method] = {'error': 'Method {} failed: {}'.format(method, e)}
d.close()
return Response(response)

View File

@@ -226,12 +226,12 @@ IFACE_MODE_CHOICES = [
# Pass-through port types
PORT_TYPE_8P8C = 1000
PORT_TYPE_ST = 2000
PORT_TYPE_SC_SIMPLEX = 2100
PORT_TYPE_SC_DUPLEX = 2110
PORT_TYPE_SC = 2100
PORT_TYPE_FC = 2200
PORT_TYPE_LC = 2300
PORT_TYPE_MTRJ = 2400
PORT_TYPE_MPO = 2500
PORT_TYPE_LSH = 2600
PORT_TYPE_CHOICES = [
[
'Copper',
@@ -242,13 +242,13 @@ PORT_TYPE_CHOICES = [
[
'Fiber Optic',
[
[PORT_TYPE_ST, 'ST'],
[PORT_TYPE_SC_SIMPLEX, 'SC (Simplex)'],
[PORT_TYPE_SC_DUPLEX, 'SC (Duplex)'],
[PORT_TYPE_FC, 'FC'],
[PORT_TYPE_LC, 'LC'],
[PORT_TYPE_MTRJ, 'MTRJ'],
[PORT_TYPE_LSH, 'LSH'],
[PORT_TYPE_MPO, 'MPO'],
[PORT_TYPE_MTRJ, 'MTRJ'],
[PORT_TYPE_SC, 'SC'],
[PORT_TYPE_ST, 'ST'],
]
]
]

View File

@@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NumericInFilter
from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter
from virtualization.models import Cluster
from .constants import *
from .models import (
@@ -81,9 +81,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Site
@@ -202,9 +200,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Role (slug)',
)
asset_tag = NullableCharFieldFilter()
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Rack
@@ -303,7 +299,7 @@ class ManufacturerFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class DeviceTypeFilter(CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -322,33 +318,31 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
console_ports = django_filters.CharFilter(
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label='Has console ports',
)
console_server_ports = django_filters.CharFilter(
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
label='Has console server ports',
)
power_ports = django_filters.CharFilter(
power_ports = django_filters.BooleanFilter(
method='_power_ports',
label='Has power ports',
)
power_outlets = django_filters.CharFilter(
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
label='Has power outlets',
)
interfaces = django_filters.CharFilter(
interfaces = django_filters.BooleanFilter(
method='_interfaces',
label='Has interfaces',
)
pass_through_ports = django_filters.CharFilter(
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
label='Has pass-through ports',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = DeviceType
@@ -367,30 +361,24 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
def _console_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(consoleport_templates__isnull=bool(value))
return queryset.exclude(consoleport_templates__isnull=value)
def _console_server_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(consoleserverport_templates__isnull=bool(value))
return queryset.exclude(consoleserverport_templates__isnull=value)
def _power_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(powerport_templates__isnull=bool(value))
return queryset.exclude(powerport_templates__isnull=value)
def _power_outlets(self, queryset, name, value):
value = value.strip()
return queryset.exclude(poweroutlet_templates__isnull=bool(value))
return queryset.exclude(poweroutlet_templates__isnull=value)
def _interfaces(self, queryset, name, value):
value = value.strip()
return queryset.exclude(interface_templates__isnull=bool(value))
return queryset.exclude(interface_templates__isnull=value)
def _pass_through_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(
frontport_templates__isnull=bool(value),
rearport_templates__isnull=bool(value)
frontport_templates__isnull=value,
rearport_templates__isnull=value
)
@@ -483,7 +471,7 @@ class PlatformFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
class DeviceFilter(CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -588,30 +576,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
field_name='device_type__is_full_depth',
label='Is full depth',
)
console_ports = django_filters.CharFilter(
method='_console_ports',
label='Has console ports',
)
console_server_ports = django_filters.CharFilter(
method='_console_server_ports',
label='Has console server ports',
)
power_ports = django_filters.CharFilter(
method='_power_ports',
label='Has power ports',
)
power_outlets = django_filters.CharFilter(
method='_power_outlets',
label='Has power outlets',
)
interfaces = django_filters.CharFilter(
method='_interfaces',
label='Has interfaces',
)
pass_through_ports = django_filters.CharFilter(
method='_pass_through_ports',
label='Has pass-through ports',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
@@ -625,9 +589,31 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label='Has console ports',
)
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
label='Has console server ports',
)
power_ports = django_filters.BooleanFilter(
method='_power_ports',
label='Has power ports',
)
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
label='Has power outlets',
)
interfaces = django_filters.BooleanFilter(
method='_interfaces',
label='Has interfaces',
)
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
label='Has pass-through ports',
)
tag = TagFilter()
class Meta:
model = Device
@@ -677,30 +663,24 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
def _console_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(consoleports__isnull=bool(value))
return queryset.exclude(consoleports__isnull=value)
def _console_server_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(consoleserverports__isnull=bool(value))
return queryset.exclude(consoleserverports__isnull=value)
def _power_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(powerports__isnull=bool(value))
return queryset.exclude(powerports__isnull=value)
def _power_outlets(self, queryset, name, value):
value = value.strip()
return queryset.exclude(poweroutlets__isnull=bool(value))
return queryset.exclude(poweroutlets_isnull=value)
def _interfaces(self, queryset, name, value):
value = value.strip()
return queryset.exclude(interfaces__isnull=bool(value))
return queryset.exclude(interfaces__isnull=value)
def _pass_through_ports(self, queryset, name, value):
value = value.strip()
return queryset.exclude(
frontports__isnull=bool(value),
rearports__isnull=bool(value)
frontports__isnull=value,
rearports__isnull=value
)
@@ -714,9 +694,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class ConsolePortFilter(DeviceComponentFilterSet):
@@ -775,9 +753,7 @@ class InterfaceFilter(django_filters.FilterSet):
method='_mac_address',
label='MAC address',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
@@ -932,9 +908,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = VirtualChassis

View File

@@ -19,7 +19,7 @@ from utilities.forms import (
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithPK, SmallTextarea,
SlugField, COLOR_CHOICES,
SlugField, BOOLEAN_WITH_BLANK_CHOICES, COLOR_CHOICES,
)
from virtualization.models import Cluster
@@ -650,35 +650,41 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug'
)
console_ports = forms.BooleanField(
required=False,
label='Has console ports'
)
console_server_ports = forms.BooleanField(
required=False,
label='Has console server ports'
)
power_ports = forms.BooleanField(
required=False,
label='Has power ports'
)
power_outlets = forms.BooleanField(
required=False,
label='Has power outlets'
)
interfaces = forms.BooleanField(
required=False,
label='Has interfaces'
)
pass_through_ports = forms.BooleanField(
required=False,
label='Has pass-through ports'
)
subdevice_role = forms.NullBooleanField(
required=False,
label='Subdevice role',
widget=forms.Select(choices=add_blank_choice(SUBDEVICE_ROLE_CHOICES))
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
#
@@ -1316,11 +1322,37 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
])
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES)
)
@@ -1653,13 +1685,13 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = FrontPort
fields = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'tags']
fields = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
@@ -1672,6 +1704,9 @@ class FrontPortCreateForm(ComponentForm):
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.'
)
description = forms.CharField(
required=False
)
def __init__(self, *args, **kwargs):
@@ -1723,6 +1758,13 @@ class FrontPortBulkRenameForm(BulkRenameForm):
)
class FrontPortBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(
queryset=FrontPort.objects.all(),
widget=forms.MultipleHiddenInput
)
#
# Rear pass-through ports
#
@@ -1732,7 +1774,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = RearPort
fields = ['device', 'name', 'type', 'positions', 'tags']
fields = ['device', 'name', 'type', 'positions', 'description', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1751,6 +1793,9 @@ class RearPortCreateForm(ComponentForm):
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
description = forms.CharField(
required=False
)
class RearPortBulkRenameForm(BulkRenameForm):
@@ -1760,6 +1805,13 @@ class RearPortBulkRenameForm(BulkRenameForm):
)
class RearPortBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(
queryset=RearPort.objects.all(),
widget=forms.MultipleHiddenInput
)
#
# Cables
#
@@ -1819,7 +1871,10 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
label='Name',
widget=APISelect(
api_url='/api/dcim/{{termination_b_type}}s/?device_id={{termination_b_device}}',
disabled_indicator='cable'
disabled_indicator='cable',
url_conditional_append={
'termination_b_type__interface': '&type=physical',
}
)
)
@@ -1837,6 +1892,8 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
termination_a_type = self.instance.termination_a._meta.model_name
self.fields['termination_b_type'].queryset = ContentType.objects.filter(
model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type)
).exclude(
model='circuittermination'
)

View File

@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=64)),
('type', models.PositiveSmallIntegerField()),
('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.Device')),
],
options={
@@ -44,6 +45,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=64)),
('type', models.PositiveSmallIntegerField()),
('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.Device')),
('tags', taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag')),
],

View File

@@ -34,8 +34,13 @@ def console_connections_to_cables(apps, schema_editor):
)
# Cache the Cable on its two termination points
ConsolePort.objects.filter(pk=consoleport.id).update(cable=cable)
ConsoleServerPort.objects.filter(pk=consoleport.connected_endpoint_id).update(cable=cable)
ConsolePort.objects.filter(pk=consoleport.id).update(
cable=cable
)
ConsoleServerPort.objects.filter(pk=consoleport.connected_endpoint_id).update(
connection_status=consoleport.connection_status,
cable=cable
)
cable_count = Cable.objects.filter(termination_a_type=consoleport_type).count()
if 'test' not in sys.argv:
@@ -70,8 +75,13 @@ def power_connections_to_cables(apps, schema_editor):
)
# Cache the Cable on its two termination points
PowerPort.objects.filter(pk=powerport.id).update(cable=cable)
PowerOutlet.objects.filter(pk=powerport.connected_endpoint_id).update(cable=cable)
PowerPort.objects.filter(pk=powerport.id).update(
cable=cable
)
PowerOutlet.objects.filter(pk=powerport.connected_endpoint_id).update(
connection_status=powerport.connection_status,
cable=cable
)
cable_count = Cable.objects.filter(termination_a_type=powerport_type).count()
if 'test' not in sys.argv:
@@ -174,6 +184,11 @@ class Migration(migrations.Migration):
name='connected_endpoint',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.ConsoleServerPort'),
),
migrations.AlterField(
model_name='consoleport',
name='connection_status',
field=models.NullBooleanField(),
),
migrations.AddField(
model_name='consoleport',
name='cable',
@@ -189,6 +204,11 @@ class Migration(migrations.Migration):
name='cable',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'),
),
migrations.AddField(
model_name='consoleserverport',
name='connection_status',
field=models.NullBooleanField(),
),
# Alter power port models
migrations.RenameField(
@@ -206,6 +226,11 @@ class Migration(migrations.Migration):
name='connected_endpoint',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.PowerOutlet'),
),
migrations.AlterField(
model_name='powerport',
name='connection_status',
field=models.NullBooleanField(),
),
migrations.AddField(
model_name='powerport',
name='cable',
@@ -221,6 +246,11 @@ class Migration(migrations.Migration):
name='cable',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'),
),
migrations.AddField(
model_name='poweroutlet',
name='connection_status',
field=models.NullBooleanField(),
),
# Alter the Interface model
migrations.AddField(
@@ -236,7 +266,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='interface',
name='connection_status',
field=models.NullBooleanField(default=True),
field=models.NullBooleanField(),
),
migrations.AddField(
model_name='interface',
@@ -274,4 +304,35 @@ class Migration(migrations.Migration):
name='InterfaceConnection',
),
# Proxy models
migrations.CreateModel(
name='ConsoleConnection',
fields=[
],
options={
'proxy': True,
'indexes': [],
},
bases=('dcim.consoleport',),
),
migrations.CreateModel(
name='InterfaceConnection',
fields=[
],
options={
'proxy': True,
'indexes': [],
},
bases=('dcim.interface',),
),
migrations.CreateModel(
name='PowerConnection',
fields=[
],
options={
'proxy': True,
'indexes': [],
},
bases=('dcim.powerport',),
),
]

View File

@@ -73,10 +73,22 @@ class CableTermination(models.Model):
null=True
)
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
_cabled_as_a = GenericRelation(
to='dcim.Cable',
content_type_field='termination_a_type',
object_id_field='termination_a_id'
)
_cabled_as_b = GenericRelation(
to='dcim.Cable',
content_type_field='termination_b_type',
object_id_field='termination_b_id'
)
class Meta:
abstract = True
def trace(self, position=1):
def trace(self, position=1, follow_circuits=False):
"""
Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
[
@@ -85,7 +97,7 @@ class CableTermination(models.Model):
(termination E, cable, termination F)
]
"""
def get_peer_port(termination, position=1):
def get_peer_port(termination, position=1, follow_circuits=False):
from circuits.models import CircuitTermination
# Map a front port to its corresponding rear port
@@ -105,7 +117,7 @@ class CableTermination(models.Model):
return peer_port, 1
# Follow a circuit to its other termination
elif isinstance(termination, CircuitTermination):
elif isinstance(termination, CircuitTermination) and follow_circuits:
peer_termination = termination.get_peer_termination()
if peer_termination is None:
return None, None
@@ -121,7 +133,7 @@ class CableTermination(models.Model):
far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
path = [(self, self.cable, far_end)]
peer_port, position = get_peer_port(far_end, position)
peer_port, position = get_peer_port(far_end, position, follow_circuits)
if peer_port is None:
return path
@@ -540,12 +552,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def clean(self):
# Validate outer dimensions and unit
if self.outer_width and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width")
if self.outer_depth and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer depth")
if self.outer_unit and self.outer_width is None and self.outer_depth is None:
self.length_unit = ''
if (self.outer_width or self.outer_depth) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")
else:
self.outer_unit = ''
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
@@ -1692,13 +1702,13 @@ class ConsolePort(CableTermination, ComponentModel):
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['console_server', 'connected_endpoint', 'device', 'console_port', 'connection_status']
csv_headers = ['device', 'name']
class Meta:
ordering = ['device', 'name']
@@ -1712,11 +1722,8 @@ class ConsolePort(CableTermination, ComponentModel):
def to_csv(self):
return (
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
self.connected_endpoint.name if self.connected_endpoint else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
)
@@ -1736,10 +1743,16 @@ class ConsoleServerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
class Meta:
unique_together = ['device', 'name']
@@ -1749,6 +1762,12 @@ class ConsoleServerPort(CableTermination, ComponentModel):
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.device.identifier,
self.name,
)
#
# Power ports
@@ -1775,13 +1794,13 @@ class PowerPort(CableTermination, ComponentModel):
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['pdu', 'connected_endpoint', 'device', 'power_port', 'connection_status']
csv_headers = ['device', 'name']
class Meta:
ordering = ['device', 'name']
@@ -1795,11 +1814,8 @@ class PowerPort(CableTermination, ComponentModel):
def to_csv(self):
return (
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
self.connected_endpoint.name if self.connected_endpoint else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
)
@@ -1819,10 +1835,16 @@ class PowerOutlet(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
class Meta:
unique_together = ['device', 'name']
@@ -1832,6 +1854,12 @@ class PowerOutlet(CableTermination, ComponentModel):
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.device.identifier,
self.name,
)
#
# Interfaces
@@ -1875,7 +1903,7 @@ class Interface(CableTermination, ComponentModel):
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
blank=True
)
lag = models.ForeignKey(
to='self',
@@ -1935,6 +1963,11 @@ class Interface(CableTermination, ComponentModel):
objects = InterfaceManager()
tags = TaggableManager()
csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
'description', 'mode',
]
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
@@ -1945,6 +1978,21 @@ class Interface(CableTermination, ComponentModel):
def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name,
self.lag.name if self.lag else None,
self.get_form_factor_display(),
self.enabled,
self.mac_address,
self.mtu,
self.mgmt_only,
self.description,
self.get_mode_display(),
)
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
@@ -2108,10 +2156,16 @@ class FrontPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
description = models.CharField(
max_length=100,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = [
@@ -2122,6 +2176,16 @@ class FrontPort(CableTermination, ComponentModel):
def __str__(self):
return self.name
def to_csv(self):
return (
self.device.identifier,
self.name,
self.get_type_display(),
self.rear_port.name,
self.rear_port_position,
self.description,
)
def clean(self):
# Validate rear port assignment
@@ -2158,10 +2222,16 @@ class RearPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
description = models.CharField(
max_length=100,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'positions', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
@@ -2169,6 +2239,15 @@ class RearPort(CableTermination, ComponentModel):
def __str__(self):
return self.name
def to_csv(self):
return (
self.device.identifier,
self.name,
self.get_type_display(),
self.positions,
self.description,
)
#
# Device bays
@@ -2198,6 +2277,8 @@ class DeviceBay(ComponentModel):
objects = DeviceComponentManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'installed_device']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
@@ -2208,6 +2289,13 @@ class DeviceBay(ComponentModel):
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.device.identifier,
self.name,
self.installed_device.identifier if self.installed_device else None,
)
def clean(self):
# Validate that the parent Device can have DeviceBays
@@ -2456,6 +2544,21 @@ class Cable(ChangeLoggedModel):
self.termination_a_type, self.termination_b_type
))
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
@@ -2466,6 +2569,20 @@ class Cable(ChangeLoggedModel):
self.termination_b, self.termination_b.cable_id
))
# Virtual interfaces cannot be connected
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
if (
(
isinstance(endpoint_a, Interface) and
endpoint_a.form_factor == IFACE_FF_VIRTUAL
) or
(
isinstance(endpoint_b, Interface) and
endpoint_b.form_factor == IFACE_FF_VIRTUAL
)
):
raise ValidationError("Cannot connect to a virtual interface")
# Validate length and length_unit
if self.length and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
@@ -2499,39 +2616,79 @@ class Cable(ChangeLoggedModel):
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
None.
"""
def trace_cable(termination, position=1):
a_path = self.termination_b.trace()
b_path = self.termination_a.trace()
# Given a front port, follow the cable connected to the corresponding rear port/position
if isinstance(termination, FrontPort):
peer_port = termination.rear_port
position = termination.rear_port_position
# Determine overall path status (connected or planned)
if self.status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
else:
path_status = CONNECTION_STATUS_CONNECTED
for segment in a_path[1:] + b_path[1:]:
if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
break
# Given a rear port/position, follow the cable connected to the corresponding front port
elif isinstance(termination, RearPort):
if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position
))
peer_port = FrontPort.objects.get(
rear_port=termination,
rear_port_position=position,
)
position = 1
# (A path end, B path end, connected/planned)
return a_path[-1][2], b_path[-1][2], path_status
# Termination is not a pass-through port, so we've reached the end of the path
else:
return termination
# Find the cable (if any) attached to the peer port
next_cable = peer_port.cable
#
# Connection proxy models
#
# If no cable exists, return None
if next_cable is None:
return None
class ConsoleConnection(ConsolePort):
far_end = next_cable.termination_b if next_cable.termination_a == peer_port else next_cable.termination_a
csv_headers = [
'console_server', 'port', 'device', 'console_port', 'connection_status',
]
# Return the far side termination of the cable
return trace_cable(far_end, position)
class Meta:
proxy = True
return trace_cable(self.termination_a), trace_cable(self.termination_b)
def to_csv(self):
return (
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
self.connected_endpoint.name if self.connected_endpoint else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
)
class PowerConnection(PowerPort):
csv_headers = [
'pdu', 'outlet', 'device', 'power_port', 'connection_status',
]
class Meta:
proxy = True
def to_csv(self):
return (
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
self.connected_endpoint.name if self.connected_endpoint else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
)
class InterfaceConnection(Interface):
csv_headers = [
'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status',
]
class Meta:
proxy = True
def to_csv(self):
return (
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
self.connected_endpoint.name if self.connected_endpoint else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
)

View File

@@ -23,35 +23,45 @@ def clear_virtualchassis_members(instance, **kwargs):
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, **kwargs):
"""
When a Cable is saved, check for and update its two connected endpoints
"""
# Cache the Cable on its two termination points
instance.termination_a.cable = instance
instance.termination_a.save()
instance.termination_b.cable = instance
instance.termination_b.save()
if instance.termination_a.cable != instance:
instance.termination_a.cable = instance
instance.termination_a.save()
if instance.termination_b.cable != instance:
instance.termination_b.cable = instance
instance.termination_b.save()
# Check if this Cable has formed a complete path. If so, update both endpoints.
endpoint_a, endpoint_b = instance.get_path_endpoints()
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
if endpoint_a is not None and endpoint_b is not None:
endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = True
endpoint_a.connection_status = path_status
endpoint_a.save()
endpoint_b.connected_endpoint = endpoint_a
endpoint_b.connection_status = True
endpoint_b.connection_status = path_status
endpoint_b.save()
@receiver(pre_delete, sender=Cable)
def nullify_connected_endpoints(instance, **kwargs):
"""
When a Cable is deleted, check for and update its two connected endpoints
"""
endpoint_a, endpoint_b, _ = instance.get_path_endpoints()
# Disassociate the Cable from its termination points
instance.termination_a.cable = None
instance.termination_a.save()
instance.termination_b.cable = None
instance.termination_b.save()
if instance.termination_a is not None:
instance.termination_a.cable = None
instance.termination_a.save()
if instance.termination_b is not None:
instance.termination_b.cable = None
instance.termination_b.save()
# If this Cable was part of a complete path, tear it down
endpoint_a, endpoint_b = instance.get_path_endpoints()
if endpoint_a is not None and endpoint_b is not None:
endpoint_a.connected_endpoint = None
endpoint_a.connection_status = None

View File

@@ -4,10 +4,11 @@ from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
Cable, ConsoleConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceConnection,
InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerConnection, PowerOutlet, PowerOutletTemplate,
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
REGION_LINK = """
@@ -260,7 +261,7 @@ class RackRoleTable(BaseTable):
verbose_name='')
class Meta(BaseTable.Meta):
model = RackGroup
model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
@@ -593,7 +594,7 @@ class FrontPortTable(BaseTable):
class Meta(BaseTable.Meta):
model = FrontPort
fields = ('name', 'type', 'rear_port', 'rear_port_position')
fields = ('name', 'type', 'rear_port', 'rear_port_position', 'description')
empty_text = "None"
@@ -601,7 +602,7 @@ class RearPortTable(BaseTable):
class Meta(BaseTable.Meta):
model = RearPort
fields = ('name', 'type', 'positions')
fields = ('name', 'type', 'positions', 'description')
empty_text = "None"
@@ -668,19 +669,22 @@ class ConsoleConnectionTable(BaseTable):
viewname='dcim:device',
accessor=Accessor('connected_endpoint.device'),
args=[Accessor('connected_endpoint.device.pk')],
verbose_name='Console server'
verbose_name='Console Server'
)
connected_endpoint = tables.Column(verbose_name='Port')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
cable = tables.LinkColumn(
viewname='dcim:cable',
args=[Accessor('cable.pk')]
connected_endpoint = tables.Column(
verbose_name='Port'
)
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
name = tables.Column(
verbose_name='Console Port'
)
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('console_server', 'connected_endpoint', 'device', 'name', 'cable')
model = ConsoleConnection
fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status')
class PowerConnectionTable(BaseTable):
@@ -690,17 +694,20 @@ class PowerConnectionTable(BaseTable):
args=[Accessor('connected_endpoint.device.pk')],
verbose_name='PDU'
)
connected_endpoint = tables.Column(verbose_name='Outlet')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Power Port')
cable = tables.LinkColumn(
viewname='dcim:cable',
args=[Accessor('cable.pk')]
connected_endpoint = tables.Column(
verbose_name='Outlet'
)
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
name = tables.Column(
verbose_name='Power Port'
)
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('pdu', 'connected_endpoint', 'device', 'name', 'cable')
model = PowerConnection
fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status')
class InterfaceConnectionTable(BaseTable):
@@ -736,14 +743,12 @@ class InterfaceConnectionTable(BaseTable):
accessor=Accessor('connected_endpoint.description'),
verbose_name='Description'
)
cable = tables.LinkColumn(
viewname='dcim:cable',
args=[Accessor('cable.pk')]
)
class Meta(BaseTable.Meta):
model = Interface
fields = ('device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'cable')
model = InterfaceConnection
fields = (
'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status',
)
#

View File

@@ -3433,7 +3433,7 @@ class VirtualChassisTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'url']
['id', 'master', 'url']
)
def test_create_virtualchassis(self):

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from dcim.constants import *
from dcim.models import *
@@ -149,3 +150,198 @@ class RackTestCase(TestCase):
face=None,
)
self.assertTrue(pdu)
class CableTestCase(TestCase):
def setUp(self):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
self.cable.save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
self.patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
)
self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000)
self.front_port = FrontPort.objects.create(
device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port
)
def test_cable_creation(self):
"""
When a new Cable is created, it must be cached on either termination point.
"""
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(self.cable.termination_a, interface1)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_b, interface2)
def test_cable_deletion(self):
"""
When a Cable is deleted, the `cable` field on its termination points must be nullified.
"""
self.cable.delete()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable)
def test_cabletermination_deletion(self):
"""
When a CableTermination object is deleted, its attached Cable (if any) must also be deleted.
"""
self.interface1.delete()
cable = Cable.objects.filter(pk=self.cable.pk).first()
self.assertIsNone(cable)
def test_cable_validates_compatibale_types(self):
"""
The clean method should have a check to ensure only compatiable port types can be connected by a cable
"""
# An interface cannot be connected to a power port
cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_have_the_same_terminination_on_both_ends(self):
"""
A cable cannot be made with the same A and B side terminations
"""
cable = Cable(termination_a=self.interface1, termination_b=self.interface1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
"""
A cable cannot connect a front port to its sorresponding rear port
"""
cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_be_connected_to_an_existing_connection(self):
"""
Either side of a cable cannot be terminated when that side aready has a connection
"""
# Try to create a cable with the same interface terminations
cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_connect_to_a_virtual_inteface(self):
"""
A cable connection cannot include a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", form_factor=0)
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
with self.assertRaises(ValidationError):
cable.clean()
class CablePathTestCase(TestCase):
def setUp(self):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.panel1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=site
)
self.panel2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site
)
self.rear_port1 = RearPort.objects.create(
device=self.panel1, name='Rear Port 1', type=PORT_TYPE_8P8C
)
self.front_port1 = FrontPort.objects.create(
device=self.panel1, name='Front Port 1', type=PORT_TYPE_8P8C, rear_port=self.rear_port1
)
self.rear_port2 = RearPort.objects.create(
device=self.panel2, name='Rear Port 2', type=PORT_TYPE_8P8C
)
self.front_port2 = FrontPort.objects.create(
device=self.panel2, name='Front Port 2', type=PORT_TYPE_8P8C, rear_port=self.rear_port2
)
def test_path_completion(self):
# First segment
cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1)
cable1.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.connected_endpoint)
self.assertIsNone(interface1.connection_status)
# Second segment
cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
cable2.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.connected_endpoint)
self.assertIsNone(interface1.connection_status)
# Third segment
cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2, status=CONNECTION_STATUS_PLANNED)
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED)
# Switch third segment from planned to connected
cable3.status = CONNECTION_STATUS_CONNECTED
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
def test_path_teardown(self):
# Build the path
cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1)
cable1.save()
cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
cable2.save()
cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2)
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
# Remove a cable
cable2.delete()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.connected_endpoint)
self.assertIsNone(interface1.connection_status)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.connected_endpoint)
self.assertIsNone(interface2.connection_status)

View File

@@ -169,13 +169,13 @@ urlpatterns = [
# Console server ports
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
# Power ports
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
@@ -189,19 +189,18 @@ urlpatterns = [
# Power outlets
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
url(r'^power-outlets/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^interfaces/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
@@ -211,6 +210,7 @@ urlpatterns = [
url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
url(r'^interfaces/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
# Front ports
# url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
@@ -221,6 +221,7 @@ urlpatterns = [
url(r'^front-ports/(?P<pk>\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
url(r'^front-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
# Rear ports
# url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
@@ -231,6 +232,7 @@ urlpatterns = [
url(r'^rear-ports/(?P<pk>\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'),
url(r'^rear-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
# Device bays
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),

View File

@@ -24,10 +24,11 @@ from utilities.views import (
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
Cable, ConsoleConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceConnection,
InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerConnection, PowerOutlet, PowerOutletTemplate,
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
@@ -73,7 +74,7 @@ class BulkRenameView(GetReturnURLMixin, View):
})
class BulkDisconnectView(View):
class BulkDisconnectView(GetReturnURLMixin, View):
"""
An extendable view for disconnection console/power/interface components in bulk.
"""
@@ -81,22 +82,30 @@ class BulkDisconnectView(View):
form = None
template_name = 'dcim/bulk_disconnect.html'
def disconnect_objects(self, objects):
raise NotImplementedError()
def post(self, request):
def post(self, request, pk):
device = get_object_or_404(Device, pk=pk)
selected_objects = []
return_url = self.get_return_url(request)
if '_confirm' in request.POST:
form = self.form(request.POST)
if form.is_valid():
count = self.disconnect_objects(form.cleaned_data['pk'])
messages.success(request, "Disconnected {} {} on {}".format(
count, self.model._meta.verbose_name_plural, device
with transaction.atomic():
count = 0
for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']):
if obj.cable is None:
continue
obj.cable.delete()
count += 1
messages.success(request, "Disconnected {} {}".format(
count, self.model._meta.verbose_name_plural
))
return redirect(device.get_absolute_url())
return redirect(return_url)
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
@@ -104,10 +113,9 @@ class BulkDisconnectView(View):
return render(request, self.template_name, {
'form': form,
'device': device,
'obj_type_plural': self.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': device.get_absolute_url(),
'return_url': return_url,
})
@@ -892,7 +900,7 @@ class DeviceView(View):
interfaces = device.vc_interfaces.select_related(
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
).prefetch_related(
'cable__termination_a', 'cable__termination_b', 'ip_addresses'
'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
)
# Front ports
@@ -1138,14 +1146,6 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
model = ConsoleServerPort
form = forms.ConsoleServerPortBulkDisconnectForm
def disconnect_objects(self, consoleserverports):
return ConsolePort.objects.filter(
connected_endpoint__in=consoleserverports
).update(
connected_endpoint=None,
connection_status=None
)
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport'
@@ -1222,11 +1222,6 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
model = PowerOutlet
form = forms.PowerOutletBulkDisconnectForm
def disconnect_objects(self, poweroutlets):
return PowerPort.objects.filter(connected_endpoint__in=poweroutlets).update(
connected_endpoint=None, connection_status=None
)
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet'
@@ -1302,17 +1297,6 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
model = Interface
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_interface'
model = Interface
form = forms.InterfaceBulkDisconnectForm
def disconnect_objects(self, interfaces):
return Interface.objects.filter(_connected_interface__in=interfaces).update(
_connected_interface=None, connection_status=None
)
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
queryset = Interface.objects.all()
@@ -1327,6 +1311,12 @@ class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
form = forms.InterfaceBulkRenameForm
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_interface'
model = Interface
form = forms.InterfaceBulkDisconnectForm
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
queryset = Interface.objects.all()
@@ -1365,6 +1355,12 @@ class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
form = forms.FrontPortBulkRenameForm
class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_frontport'
model = FrontPort
form = forms.FrontPortBulkDisconnectForm
class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_frontport'
queryset = FrontPort.objects.all()
@@ -1403,6 +1399,12 @@ class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
form = forms.RearPortBulkRenameForm
class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
permission_required = 'dcim.change_rearport'
model = RearPort
form = forms.RearPortBulkDisconnectForm
class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rearport'
queryset = RearPort.objects.all()
@@ -1623,7 +1625,7 @@ class CableTraceView(View):
return render(request, 'dcim/cable_trace.html', {
'obj': obj,
'trace': obj.trace(),
'trace': obj.trace(follow_circuits=True),
})
@@ -1686,7 +1688,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class ConsoleConnectionsListView(ObjectListView):
queryset = ConsolePort.objects.select_related(
queryset = ConsoleConnection.objects.select_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
@@ -1700,7 +1702,7 @@ class ConsoleConnectionsListView(ObjectListView):
class PowerConnectionsListView(ObjectListView):
queryset = PowerPort.objects.select_related(
queryset = PowerConnection.objects.select_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
@@ -1714,7 +1716,7 @@ class PowerConnectionsListView(ObjectListView):
class InterfaceConnectionsListView(ObjectListView):
queryset = Interface.objects.select_related(
queryset = InterfaceConnection.objects.select_related(
'device', 'cable', '_connected_interface__device'
).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair

View File

@@ -0,0 +1,23 @@
from rest_framework import serializers
from extras.models import ReportResult
__all__ = [
'NestedReportResultSerializer',
]
#
# Reports
#
class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='report',
lookup_url_kwarg='pk'
)
class Meta:
model = ReportResult
fields = ['url', 'created', 'user', 'failed']

View File

@@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import (
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
NestedRegionSerializer, NestedSiteSerializer,
)
@@ -11,12 +11,13 @@ from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
)
from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.serializers import NestedUserSerializer
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import (
ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer,
)
from .nested_serializers import *
#
@@ -187,18 +188,6 @@ class ReportResultSerializer(serializers.ModelSerializer):
fields = ['created', 'user', 'failed', 'data']
class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='report',
lookup_url_kwarg='pk'
)
class Meta:
model = ReportResult
fields = ['url', 'created', 'user', 'failed']
class ReportSerializer(serializers.Serializer):
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)

View File

@@ -49,7 +49,7 @@ GRAPH_TYPE_CHOICES = (
EXPORTTEMPLATE_MODELS = [
'provider', 'circuit', # Circuits
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
'consoleport', 'powerport', 'interface', 'virtualchassis', # DCIM
'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy

View File

@@ -11,8 +11,8 @@ from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
JSONField, SlugField,
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
)
from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -206,6 +206,11 @@ class AddRemoveTagsForm(forms.Form):
self.fields['remove_tags'] = TagField(required=False)
class TagFilterForm(BootstrapMixin, forms.Form):
model = Tag
q = forms.CharField(required=False, label='Search')
#
# Config contexts
#
@@ -225,6 +230,28 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
]
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
widget=forms.MultipleHiddenInput
)
weight = forms.IntegerField(
required=False,
min_value=0
)
is_active = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
description = forms.CharField(
required=False,
max_length=100
)
class Meta:
nullable_fields = ['description']
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,

View File

@@ -1,4 +1,4 @@
# Generated by Django 2.0.9 on 2018-11-01 18:39
# Generated by Django 2.1.3 on 2018-11-07 20:46
from django.db import migrations, models
import django.db.models.deletion
@@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='exporttemplate',
name='content_type',
field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interface', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
]

View File

@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Q
from django.db.models import F, Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
@@ -512,6 +512,7 @@ class TopologyMap(models.Model):
).filter(
Q(device__in=devices) | Q(_connected_interface__device__in=devices),
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
)
for interface in connected_interfaces:
style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'

View File

@@ -1,5 +1,6 @@
import django_tables2 as tables
from taggit.models import Tag
from django_tables2.utils import Accessor
from taggit.models import Tag, TaggedItem
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import ConfigContext, ObjectChange
@@ -13,6 +14,14 @@ TAG_ACTIONS = """
{% endif %}
"""
TAGGED_ITEM = """
{% if value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
{{ value }}
{% endif %}
"""
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -53,6 +62,10 @@ OBJECTCHANGE_REQUEST_ID = """
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='extras:tag',
args=[Accessor('slug')]
)
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right'}},
@@ -64,6 +77,21 @@ class TagTable(BaseTable):
fields = ('pk', 'name', 'items', 'slug', 'actions')
class TaggedItemTable(BaseTable):
content_object = tables.TemplateColumn(
template_code=TAGGED_ITEM,
orderable=False,
verbose_name='Object'
)
content_type = tables.Column(
verbose_name='Type'
)
class Meta(BaseTable.Meta):
model = TaggedItem
fields = ('content_object', 'content_type')
class ConfigContextTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()

View File

@@ -7,6 +7,7 @@ urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
@@ -14,6 +15,7 @@ urlpatterns = [
# Config contexts
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),

View File

@@ -1,4 +1,5 @@
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
@@ -7,15 +8,20 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from taggit.models import Tag
from django_tables2 import RequestConfig
from taggit.models import Tag, TaggedItem
from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters
from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .forms import (
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
TagFilterForm, TagForm,
)
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
#
@@ -23,11 +29,45 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable
#
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
).order_by(
'name'
)
filter = filters.TagFilter
filter_form = TagFilterForm
table = TagTable
template_name = 'extras/tag_list.html'
class TagView(View):
def get(self, request, slug):
tag = get_object_or_404(Tag, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).select_related(
'content_type'
).prefetch_related(
'content_object'
)
# Generate a table of all items tagged with this Tag
items_table = TaggedItemTable(tagged_items)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(items_table)
return render(request, 'extras/tag.html', {
'tag': tag,
'items_count': tagged_items.count(),
'items_table': items_table,
})
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'taggit.change_tag'
model = Tag
@@ -43,7 +83,11 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
).order_by(
'name'
)
table = TagTable
default_return_url = 'extras:tag_list'
@@ -83,6 +127,15 @@ class ConfigContextEditView(ConfigContextCreateView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
table = ConfigContextTable
form = ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
model = ConfigContext

View File

@@ -0,0 +1,100 @@
from rest_framework import serializers
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedAggregateSerializer',
'NestedIPAddressSerializer',
'NestedPrefixSerializer',
'NestedRIRSerializer',
'NestedRoleSerializer',
'NestedVLANGroupSerializer',
'NestedVLANSerializer',
'NestedVRFSerializer',
]
#
# VRFs
#
class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
class Meta:
model = VRF
fields = ['id', 'url', 'name', 'rd']
#
# RIRs/aggregates
#
class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
class Meta:
model = RIR
fields = ['id', 'url', 'name', 'slug']
class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta:
model = Aggregate
fields = ['id', 'url', 'family', 'prefix']
#
# VLANs
#
class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
class Meta:
model = Role
fields = ['id', 'url', 'name', 'slug']
class NestedVLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
class Meta:
model = VLANGroup
fields = ['id', 'url', 'name', 'slug']
class NestedVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
#
# Prefixes
#
class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta:
model = Prefix
fields = ['id', 'url', 'family', 'prefix']
#
# IP addresses
#
class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
fields = ['id', 'url', 'family', 'address']

View File

@@ -5,18 +5,17 @@ from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from ipam.constants import (
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
)
from ipam.constants import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.serializers import NestedTenantSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
)
from virtualization.api.serializers import NestedVirtualMachineSerializer
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import *
#
@@ -35,35 +34,8 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
class Meta:
model = VRF
fields = ['id', 'url', 'name', 'rd']
#
# Roles
#
class RoleSerializer(ValidatedModelSerializer):
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight']
class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
class Meta:
model = Role
fields = ['id', 'url', 'name', 'slug']
#
# RIRs
# RIRs/aggregates
#
class RIRSerializer(ValidatedModelSerializer):
@@ -73,18 +45,6 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private']
class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
class Meta:
model = RIR
fields = ['id', 'url', 'name', 'slug']
#
# Aggregates
#
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
@@ -98,18 +58,17 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
read_only_fields = ['family']
class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta(AggregateSerializer.Meta):
model = Aggregate
fields = ['id', 'url', 'family', 'prefix']
#
# VLAN groups
# VLANs
#
class RoleSerializer(ValidatedModelSerializer):
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight']
class VLANGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
@@ -133,18 +92,6 @@ class VLANGroupSerializer(ValidatedModelSerializer):
return data
class NestedVLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
class Meta:
model = VLANGroup
fields = ['id', 'url', 'name', 'slug']
#
# VLANs
#
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True)
@@ -176,14 +123,6 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
return data
class NestedVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
#
# Prefixes
#
@@ -206,16 +145,10 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
read_only_fields = ['family']
class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta:
model = Prefix
fields = ['id', 'url', 'family', 'prefix']
class AvailablePrefixSerializer(serializers.Serializer):
"""
Representation of a prefix which does not exist in the database.
"""
def to_representation(self, instance):
if self.context.get('vrf'):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
@@ -233,11 +166,14 @@ class AvailablePrefixSerializer(serializers.Serializer):
#
class IPAddressInterfaceSerializer(WritableNestedSerializer):
"""
Nested representation of an Interface which may belong to a Device *or* a VirtualMachine.
"""
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
device = NestedDeviceSerializer(read_only=True)
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta(InterfaceSerializer.Meta):
class Meta:
model = Interface
fields = [
'id', 'url', 'device', 'virtual_machine', 'name',
@@ -258,6 +194,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
@@ -269,20 +207,10 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
read_only_fields = ['family']
class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True)
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True)
class AvailableIPSerializer(serializers.Serializer):
"""
Representation of an IP address which does not exist in the database.
"""
def to_representation(self, instance):
if self.context.get('vrf'):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data

View File

@@ -96,25 +96,34 @@ class PrefixViewSet(CustomFieldModelViewSet):
for i, requested_prefix in enumerate(requested_prefixes):
# Validate requested prefix size
error_msg = None
if 'prefix_length' not in requested_prefix:
error_msg = "Item {}: prefix_length field missing".format(i)
elif not isinstance(requested_prefix['prefix_length'], int):
error_msg = "Item {}: Invalid prefix length ({})".format(
i, requested_prefix['prefix_length']
)
elif prefix.family == 4 and requested_prefix['prefix_length'] > 32:
error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format(
i, requested_prefix['prefix_length']
)
elif prefix.family == 6 and requested_prefix['prefix_length'] > 128:
error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format(
i, requested_prefix['prefix_length']
)
if error_msg:
prefix_length = requested_prefix.get('prefix_length')
if prefix_length is None:
return Response(
{
"detail": error_msg
"detail": "Item {}: prefix_length field missing".format(i)
},
status=status.HTTP_400_BAD_REQUEST
)
try:
prefix_length = int(prefix_length)
except ValueError:
return Response(
{
"detail": "Item {}: Invalid prefix length ({})".format(i, prefix_length),
},
status=status.HTTP_400_BAD_REQUEST
)
if prefix.family == 4 and prefix_length > 32:
return Response(
{
"detail": "Item {}: Invalid prefix length ({}) for IPv4".format(i, prefix_length),
},
status=status.HTTP_400_BAD_REQUEST
)
elif prefix.family == 6 and prefix_length > 128:
return Response(
{
"detail": "Item {}: Invalid prefix length ({}) for IPv6".format(i, prefix_length),
},
status=status.HTTP_400_BAD_REQUEST
)
@@ -131,7 +140,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_204_NO_CONTENT
)
# Remove the allocated prefix from the list of available prefixes
@@ -187,7 +196,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_204_NO_CONTENT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix

View File

@@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -32,9 +32,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
def search(self, queryset, name, value):
if not value.strip():
@@ -80,9 +78,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='RIR (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Aggregate
@@ -184,9 +180,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=PREFIX_STATUS_CHOICES,
null_value=None
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Prefix
@@ -316,9 +310,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
role = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_ROLE_CHOICES
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = IPAddress
@@ -438,9 +430,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=VLAN_STATUS_CHOICES,
null_value=None
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = VLAN
@@ -482,9 +472,7 @@ class ServiceFilter(django_filters.FilterSet):
to_field_name='name',
label='Virtual machine (name)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Service

View File

@@ -566,7 +566,7 @@ class PrefixTest(APITestCase):
# Try to create one more prefix
response = self.client.post(url, {'prefix_length': 30}, **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertIn('detail', response.data)
def test_create_multiple_available_prefixes(self):
@@ -583,7 +583,7 @@ class PrefixTest(APITestCase):
{'prefix_length': 30, 'description': 'Test Prefix 5'},
]
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertIn('detail', response.data)
# Verify that no prefixes were created (the entire /28 is still available)
@@ -628,7 +628,7 @@ class PrefixTest(APITestCase):
# Try to create one more IP
response = self.client.post(url, {}, **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self):
@@ -639,7 +639,7 @@ class PrefixTest(APITestCase):
# Try to create nine IPs (only eight are available)
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertIn('detail', response.data)
# Verify that no IPs were created (eight are still available)

View File

@@ -81,10 +81,17 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def paginate_queryset(self, queryset, request, view=None):
try:
self.count = queryset.count()
except (AttributeError, TypeError):
if hasattr(queryset, 'all'):
# TODO: This breaks filtering by annotated values
# Make a clone of the queryset with any annotations stripped (performance hack)
qs = queryset.all()
qs.query.annotations.clear()
self.count = qs.count()
else:
# We're dealing with an iterable, not a QuerySet
self.count = len(queryset)
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.request = request

View File

@@ -91,6 +91,10 @@ LOGGING = {}
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False
@@ -121,10 +125,6 @@ PAGINATE_COUNT = 50
# prefer IPv4 instead.
PREFER_IPV4 = False
# The Webhook event backend is disabled by default. Set this to True to enable it. Note that this requires a Redis
# database be configured and accessible by NetBox (see `REDIS` below).
WEBHOOKS_ENABLED = False
# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled.
REDIS = {
'HOST': 'localhost',
@@ -138,9 +138,18 @@ REDIS = {
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
SESSION_FILE_PATH = None
# Time zone (default: UTC)
TIME_ZONE = 'UTC'
# The webhooks backend is disabled by default. Set this to True to enable it. Note that this requires a Redis
# database be configured and accessible by NetBox.
WEBHOOKS_ENABLED = False
# Date/time formatting. See the following link for supported formats:
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
DATE_FORMAT = 'N j, Y'

View File

@@ -21,7 +21,7 @@ except ImportError:
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
)
VERSION = '2.5-beta1'
VERSION = '2.5-beta2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -54,6 +54,7 @@ ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
EMAIL = getattr(configuration, 'EMAIL', {})
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@@ -65,6 +66,7 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
REDIS = getattr(configuration, 'REDIS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -111,6 +113,17 @@ DATABASES = {
'default': configuration.DATABASE,
}
# Sessions
if LOGIN_TIMEOUT is not None:
if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0:
raise ImproperlyConfigured(
"LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT)
)
# Django default is 1209600 seconds (14 days)
SESSION_COOKIE_AGE = LOGIN_TIMEOUT
if SESSION_FILE_PATH is not None:
SESSION_ENGINE = 'django.contrib.sessions.backends.file'
# Redis
REDIS_HOST = REDIS.get('HOST', 'localhost')
REDIS_PORT = REDIS.get('PORT', 6379)

View File

@@ -14,7 +14,7 @@ from dcim.filters import (
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
)
from dcim.models import (
ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
)
from dcim.tables import (
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
@@ -166,6 +166,7 @@ class HomeView(View):
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
)
cables = Cable.objects.all()
stats = {
@@ -177,6 +178,7 @@ class HomeView(View):
'rack_count': Rack.objects.count(),
'device_count': Device.objects.count(),
'interface_connections_count': connected_interfaces.count(),
'cable_count': cables.count(),
'console_connections_count': connected_consoleports.count(),
'power_connections_count': connected_powerports.count(),
@@ -205,7 +207,7 @@ class HomeView(View):
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'report_results': ReportResult.objects.order_by('-created')[:10],
'changelog': ObjectChange.objects.select_related('user')[:50]
'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50]
})

View File

@@ -390,6 +390,19 @@ table.report th a {
top: -51px;
}
/* Rendered Markdown */
.rendered-markdown table {
width: 100%;
}
.rendered-markdown th {
border-bottom: 2px solid #dddddd;
padding: 8px;
}
.rendered-markdown td {
border-top: 1px solid #dddddd;
padding: 8px;
}
/* AJAX loader */
.loading {
position: fixed;

View File

@@ -91,8 +91,9 @@ $(document).ready(function() {
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
var rendered_url = api_url;
var filter_field;
while (match = filter_regex.exec(api_url)) {
var filter_field = $('#id_' + match[1]);
filter_field = $('#id_' + match[1]);
var custom_attr = $('option:selected', filter_field).attr('api-value');
if (custom_attr) {
rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -103,6 +104,18 @@ $(document).ready(function() {
}
}
// Account for any conditional URL append strings
$.each(child_field[0].attributes, function(index, attr){
if (attr.name.includes("data-url-conditional-append-")){
var conditional = attr.name.split("data-url-conditional-append-")[1].split("__");
var field = $("#id_" + conditional[0]);
var field_value = conditional[1];
if ($('option:selected', field).attr('api-value') === field_value){
rendered_url = rendered_url + attr.value;
}
}
})
// If all URL variables have been replaced, make the API call
if (rendered_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + rendered_url);

View File

@@ -42,8 +42,8 @@ $(document).ready(function() {
event.preventDefault();
search_field.val(ui.item.label);
select_fields.val('');
select_fields.attr('disabled', 'disabled');
real_field.empty();
select_fields.attr('disabled', 'disabled');
real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
real_field.change();
// Disable parent selection fields

View File

@@ -0,0 +1,16 @@
from rest_framework import serializers
from secrets.models import SecretRole
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedSecretRoleSerializer'
]
class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
class Meta:
model = SecretRole
fields = ['id', 'url', 'name', 'slug']

View File

@@ -2,14 +2,15 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceSerializer
from dcim.api.nested_serializers import NestedDeviceSerializer
from extras.api.customfields import CustomFieldModelSerializer
from secrets.models import Secret, SecretRole
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
from utilities.api import ValidatedModelSerializer
from .nested_serializers import *
#
# SecretRoles
# Secrets
#
class SecretRoleSerializer(ValidatedModelSerializer):
@@ -19,18 +20,6 @@ class SecretRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
class Meta:
model = SecretRole
fields = ['id', 'url', 'name', 'slug']
#
# Secrets
#
class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer()
role = NestedSecretRoleSerializer()

View File

@@ -3,7 +3,7 @@ from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .models import Secret, SecretRole
@@ -43,9 +43,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Secret

View File

@@ -1,4 +1,5 @@
import os
import sys
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
@@ -386,6 +387,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
s = s.encode('utf8')
if len(s) > 65535:
raise ValueError("Maximum plaintext size is 65535 bytes.")
# Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
if len(s) <= 62:
pad_length = 62 - len(s)
@@ -393,12 +395,14 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
pad_length = 16 - ((len(s) + 2) % 16)
else:
pad_length = 0
return (
chr(len(s) >> 8).encode() +
chr(len(s) % 256).encode() +
s +
os.urandom(pad_length)
)
# Python 2 compatibility
if sys.version_info[0] < 3:
header = chr(len(s) >> 8) + chr(len(s) % 256)
else:
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
return header + s + os.urandom(pad_length)
def _unpad(self, s):
"""

View File

@@ -1,3 +1,5 @@
import string
from Crypto.PublicKey import RSA
from django.conf import settings
from django.contrib.auth.models import User
@@ -86,7 +88,7 @@ class SecretTestCase(TestCase):
"""
Test basic encryption and decryption functionality using a random master key.
"""
plaintext = "FooBar123"
plaintext = string.printable * 2
secret_key = generate_random_key()
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)

View File

@@ -113,7 +113,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if circuit.comments %}
{{ circuit.comments|gfm }}
{% else %}

View File

@@ -40,13 +40,20 @@
<td>Termination</td>
<td>
{% if termination.cable %}
{% if perms.dcim.delete_cable %}
<div class="pull-right">
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i> Disconnect
</a>
</div>
{% endif %}
<a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
{% if termination.connected_endpoint %}
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
{% endif %}
{% else %}
{% if perms.circuits.change_circuittermination %}
{% if perms.circuits.add_cable %}
<div class="pull-right">
<a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect

View File

@@ -105,7 +105,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if provider.comments %}
{{ provider.comments|gfm }}
{% else %}

View File

@@ -4,7 +4,7 @@
{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
{% block message %}
<p>Are you sure you want to disconnect all {{ selected_objects|length }} of these {{ obj_type_plural }} on <strong>{{ device }}</strong>?</p>
<p>Are you sure you want to disconnect these {{ selected_objects|length }} {{ obj_type_plural }}?</p>
<ul>
{% for obj in selected_objects %}
<li>{{ obj }}</li>

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
@@ -49,6 +50,12 @@
<p class="form-control-static">{{ termination_a.device }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label required">Type</label>
<div class="col-md-9">
<p class="form-control-static">{{ termination_a|model_name|capfirst }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label required">Name</label>
<div class="col-md-9">

View File

@@ -3,6 +3,9 @@
{% block content %}
<div class="pull-right">
{% if perms.dcim.add_cable %}
{% import_button 'dcim:cable_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Cables{% endblock %}</h1>

View File

@@ -23,18 +23,24 @@
{% include 'dcim/inc/cable_trace_end.html' with end=near_end %}
</div>
<div class="col-md-3 text-center">
<h4>
<a href="{% url 'dcim:cable' pk=cable.pk %}">
{% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
</a>
</h4>
{{ cable.get_status_display }}<br />
{{ cable.get_type_display|default:"" }}
{% if cable.length %}- {{ cable.length }}{{ cable.length_unit }}{% endif %}
<span class="label color-block center-block" style="background-color: #{{ cable.color }}">&nbsp;</span>
{% if cable %}
<h4>
<a href="{% url 'dcim:cable' pk=cable.pk %}">
{% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
</a>
</h4>
{{ cable.get_status_display }}<br />
{{ cable.get_type_display|default:"" }}
{% if cable.length %}- {{ cable.length }}{{ cable.length_unit }}{% endif %}
<span class="label color-block center-block" style="background-color: #{{ cable.color }}">&nbsp;</span>
{% else %}
<h4 class="text-muted">No Cable</h4>
{% endif %}
</div>
<div class="col-md-4">
{% include 'dcim/inc/cable_trace_end.html' with end=far_end %}
{% if far_end %}
{% include 'dcim/inc/cable_trace_end.html' with end=far_end %}
{% endif %}
</div>
</div>
{% if not forloop.last %}<hr />{% endif %}

View File

@@ -36,7 +36,7 @@
<div class="pull-right">
{% if perms.dcim.change_device %}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
@@ -293,7 +293,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if device.comments %}
{{ device.comments|gfm }}
{% else %}
@@ -508,6 +508,7 @@
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>Mode</th>
<th>Cable</th>
<th colspan="2">Connection</th>
@@ -530,7 +531,7 @@
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
@@ -585,7 +586,7 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
@@ -640,7 +641,7 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
@@ -680,6 +681,7 @@
<th>Type</th>
<th>Rear Port</th>
<th>Position</th>
<th>Description</th>
<th>Connected Cable</th>
<th></th>
</tr>
@@ -695,6 +697,9 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if front_ports and perms.dcim.delete_frontport %}
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -729,6 +734,7 @@
<th>Name</th>
<th>Type</th>
<th>Positions</th>
<th>Description</th>
<th>Connected Cable</th>
<th></th>
</tr>
@@ -744,6 +750,9 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if rear_ports and perms.dcim.delete_rearport %}
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -770,8 +779,8 @@
{% block javascript %}
<script type="text/javascript">
function toggleConnection(elem, api_url) {
var url = netbox_api_path + api_url + elem.attr('data') + "/";
function toggleConnection(elem) {
var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/";
if (elem.hasClass('connected')) {
$.ajax({
url: url,
@@ -781,7 +790,7 @@ function toggleConnection(elem, api_url) {
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
},
data: {
'connection_status': 'False'
'status': 'False'
},
context: this,
success: function() {
@@ -800,7 +809,7 @@ function toggleConnection(elem, api_url) {
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
},
data: {
'connection_status': 'True'
'status': 'True'
},
context: this,
success: function() {
@@ -813,14 +822,8 @@ function toggleConnection(elem, api_url) {
}
return false;
}
$(".consoleport-toggle").click(function() {
return toggleConnection($(this), "dcim/console-ports/");
});
$(".powerport-toggle").click(function() {
return toggleConnection($(this), "dcim/power-ports/");
});
$(".interface-toggle").click(function() {
return toggleConnection($(this), "dcim/interface-connections/");
$(".cable-toggle").click(function() {
return toggleConnection($(this));
});
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {

View File

@@ -17,7 +17,7 @@
<div class="pull-right">
{% if perms.dcim.change_devicetype %}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
@@ -116,7 +116,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if devicetype.comments %}
{{ devicetype.comments|gfm }}
{% else %}

View File

@@ -1,3 +1,4 @@
{% load helpers %}
<table class="table table-hover panel-body attr-table">
{% if termination.device %}
{# Device component #}
@@ -7,6 +8,12 @@
<a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>
{{ termination|model_name|capfirst }}
</td>
</tr>
<tr>
<td>Component</td>
<td>{{ termination }}</td>

View File

@@ -0,0 +1,16 @@
{% if perms.dcim.change_cable %}
{% if cable.status %}
<a href="#" class="btn btn-warning btn-xs cable-toggle connected" title="Mark planned" data="{{ cable.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs cable-toggle" title="Mark installed" data="{{ cable.pk }}">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ device.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -1,4 +1,4 @@
<tr class="consoleport{% if cp.connected_endpoint %} {% if cp.connection_status %}success{% else %}info{% endif %}{% endif %}">
<tr class="consoleport{% if cp.cable.status %} success{% elif cp.cable %} info{% endif %}">
{# Name #}
<td>
@@ -30,25 +30,14 @@
{# Actions #}
<td class="text-right">
{% if cp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_consoleport %}
{% if cp.connected_endpoint %}
{% if cp.connection_status %}
<a href="#" class="btn btn-warning btn-xs consoleport-toggle connected" title="Mark planned" data="{{ cp.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs consoleport-toggle" title="Mark installed" data="{{ cp.pk }}">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:cable_delete' pk=cp.cable.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk %}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,4 @@
<tr class="consoleserverport{% if csp.connected_endpoint %} {%if csp.connected_endpoint.connection_status %}success{% else %}info{% endif %}{% endif %}">
<tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
@@ -37,25 +37,14 @@
{# Actions #}
<td class="text-right">
{% if csp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_consoleserverport %}
{% if csp.connected_endpoint %}
{% if csp.connected_endpoint.connection_status %}
<a href="#" class="btn btn-warning btn-xs consoleport-toggle connected" title="Mark planned" data="{{ csp.connected_endpoint.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs consoleport-toggle" title="Mark installed" data="{{ csp.connected_endpoint.pk }}">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:cable_delete' pk=csp.cable.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk %}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,5 @@
<tr class="frontport{% if frontport.cable %} {% if frontport.cable.status %}success{% else %}info{% endif %}{% endif %}">
{% load helpers %}
<tr class="frontport{% if frontport.cable.status %} success{% elif frontport.cable %} info{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_frontport or perms.dcim.delete_frontport %}
@@ -19,6 +20,9 @@
<td>{{ frontport.rear_port }}</td>
<td>{{ frontport.rear_port_position }}</td>
{# Description #}
<td>{{ frontport.description|placeholder }}</td>
{# Cable #}
<td>
{% if frontport.cable %}
@@ -30,7 +34,9 @@
{# Actions #}
<td class="text-right">
{% if perms.dcim.add_cable %}
{% if frontport.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,5 @@
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connected_endpoint %} {% if iface.connection_status %}success{% else %}info{% endif %}{% elif iface.circuit_termination %} success{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
{% load helpers %}
<tr class="interface{% if not iface.enabled %} danger{% elif iface.cable.status %} success{% elif iface.cable %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
{# Checkbox #}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
@@ -13,17 +14,32 @@
<i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}align-justify{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
<a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
</span>
{% if iface.mac_address %}
<br/><small class="text-muted">{{ iface.mac_address }}</small>
{% endif %}
</td>
{# LAG #}
<td>
{% if iface.lag %}
<a href="#iface_{{ iface.lag }}" class="label label-default">{{ iface.lag }}</a>
<a href="#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
{% endif %}
</td>
{# Description #}
<td>{{ iface.description|default:"&mdash;" }}</td>
{# Description/tags #}
<td>
{% if iface.description %}
{{ iface.description }}<br/>
{% endif %}
{% for tag in iface.tags.all %}
{% tag tag %}
{% empty %}
{% if not iface.description %}&mdash;{% endif %}
{% endfor %}
</td>
{# MTU #}
<td>{{ iface.mtu|default:"&mdash;" }}</td>
{# 802.1Q mode #}
<td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
@@ -44,7 +60,13 @@
{% if iface.is_lag %}
<td colspan="2" class="text-muted">
LAG interface<br />
<small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
<small class="text-muted">
{% for member in iface.member_interfaces.all %}
<a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
No members
{% endfor %}
</small>
</td>
{% elif iface.is_virtual %}
<td colspan="2" class="text-muted">Virtual interface</td>
@@ -104,25 +126,12 @@
</a>
{% endif %}
{% if perms.dcim.change_interface %}
{% if not iface.is_virtual %}
{% if iface.cable %}
{% if iface.cable.status %}
<a href="#" class="btn btn-warning btn-xs interface-toggle connected" data="{{ iface.cable.pk }}" title="Mark planned">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs interface-toggle" data="{{ iface.cable.pk }}" title="Mark installed">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:cable_delete' pk=iface.cable.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Remove cable">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% if iface.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
{% elif not iface.is_virtual and perms.dcim.add_cable %}
<a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
@@ -151,7 +160,7 @@
{% endif %}
{# IP addresses table #}
<td colspan="8" style="padding: 0">
<td colspan="9" style="padding: 0">
<table class="table table-condensed interface-ips">
<thead>
<tr class="text-muted">

View File

@@ -1,4 +1,4 @@
<tr class="poweroutlet{% if po.connected_endpoint %} {% if po.connected_endpoint.connection_status %}success{% else %}info{% endif %}{% endif %}">
<tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
@@ -37,25 +37,14 @@
{# Actions #}
<td class="text-right">
{% if po.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=po.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_poweroutlet %}
{% if po.connected_endpoint %}
{% if po.connected_endpoint.connection_status %}
<a href="#" class="btn btn-warning btn-xs powerport-toggle connected" title="Mark planned" data="{{ po.connected_endpoint.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs powerport-toggle" title="Mark installed" data="{{ po.connected_endpoint.pk }}">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:cable_delete' pk=po.cable.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=po.pk %}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,4 @@
<tr class="powerport{% if pp.connected_endpoint %} {% if pp.connection_status %}success{% else %}info{% endif %}{% endif %}">
<tr class="powerport{% if pp.cable.status %} success{% elif pp.cable %} info{% endif %}">
{# Name #}
<td>
@@ -30,25 +30,14 @@
{# Actions #}
<td class="text-right">
{% if pp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:powerport_connect' termination_a_id=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_powerport %}
{% if pp.connected_endpoint %}
{% if pp.connection_status %}
<a href="#" class="btn btn-warning btn-xs powerport-toggle connected" title="Mark planned" data="{{ pp.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-success btn-xs powerport-toggle" title="Mark installed" data="{{ pp.pk }}">
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:cable_delete' pk=pp.cable.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:powerport_connect' termination_a_id=pp.pk %}" title="Connect" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,5 @@
<tr class="rearport{% if rearport.cable %} {% if rearport.cable.status %}success{% else %}info{% endif %}{% endif %}">
{% load helpers %}
<tr class="rearport{% if rearport.cable.status %} success{% elif rearport.cable %} info{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_rearport or perms.dcim.delete_rearport %}
@@ -18,6 +19,9 @@
{# Positions #}
<td>{{ rearport.positions }}</td>
{# Description #}
<td>{{ rearport.description|placeholder }}</td>
{# Cable #}
<td>
{% if rearport.cable %}
@@ -29,7 +33,9 @@
{# Actions #}
<td class="text-right">
{% if perms.dcim.add_cable %}
{% if rearport.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %}
{% elif perms.dcim.add_cable %}
<a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>

View File

@@ -182,7 +182,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if rack.comments %}
{{ rack.comments|gfm }}
{% else %}

View File

@@ -200,7 +200,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if site.comments %}
{{ site.comments|gfm }}
{% else %}

View File

@@ -10,7 +10,7 @@
<h1>{% block title %}Config Contexts{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
{% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}

View File

@@ -0,0 +1,69 @@
{% extends '_base.html' %}
{% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'extras:tag_list' %}">Tags</a></li>
<li>{{ tag }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'extras:tag_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=tag.slug %}?return_url={% url 'extras:tag' slug=tag.slug %}" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit this tag
</a>
{% endif %}
</div>
<h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Tag</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>
{{ tag.name }}
</td>
</tr>
<tr>
<td>Slug</td>
<td>
{{ tag.slug }}
</td>
</tr>
<tr>
<td>Tagged Items</td>
<td>
{{ items_count }}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
</div>
</div>
{% endblock %}

View File

@@ -4,8 +4,11 @@
{% block content %}
<h1>{% block title %}Tags{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -39,6 +39,8 @@
</div>
<div class="list-group-item">
<h4 class="list-group-item-heading">Connections</h4>
<span class="badge pull-right">{{ stats.cable_count }}</span>
<p style="padding-left: 20px;"><a href="{% url 'dcim:cable_list' %}">Cables</a></p>
<span class="badge pull-right">{{ stats.interface_connections_count }}</span>
<p style="padding-left: 20px;"><a href="{% url 'dcim:interface_connections_list' %}">Interfaces</a></p>
<span class="badge pull-right">{{ stats.console_connections_count }}</span>

View File

@@ -81,7 +81,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if tenant.comments %}
{{ tenant.comments|gfm }}
{% else %}

View File

@@ -99,7 +99,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if cluster.comments %}
{{ cluster.comments|gfm }}
{% else %}

View File

@@ -143,7 +143,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if virtualmachine.comments %}
{{ virtualmachine.comments|gfm }}
{% else %}

View File

@@ -0,0 +1,29 @@
from rest_framework import serializers
from tenancy.models import Tenant, TenantGroup
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedTenantGroupSerializer',
'NestedTenantSerializer',
]
#
# Tenants
#
class NestedTenantGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta:
model = TenantGroup
fields = ['id', 'url', 'name', 'slug']
class NestedTenantSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta:
model = Tenant
fields = ['id', 'url', 'name', 'slug']

View File

@@ -1,13 +1,13 @@
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
from utilities.api import ValidatedModelSerializer
from .nested_serializers import *
#
# Tenant groups
# Tenants
#
class TenantGroupSerializer(ValidatedModelSerializer):
@@ -17,18 +17,6 @@ class TenantGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedTenantGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta:
model = TenantGroup
fields = ['id', 'url', 'name', 'slug']
#
# Tenants
#
class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
group = NestedTenantGroupSerializer(required=False)
tags = TagListSerializerField(required=False)
@@ -39,11 +27,3 @@ class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
class NestedTenantSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta:
model = Tenant
fields = ['id', 'url', 'name', 'slug']

View File

@@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .models import Tenant, TenantGroup
@@ -32,9 +32,7 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Tenant

View File

@@ -0,0 +1,18 @@
from django.contrib.auth.models import User
from utilities.api import WritableNestedSerializer
_all_ = [
'NestedUserSerializer',
]
#
# Users
#
class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = User
fields = ['id', 'username']

View File

@@ -1,10 +1,4 @@
from django.contrib.auth.models import User
from utilities.api import WritableNestedSerializer
from .nested_serializers import *
class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = User
fields = ['id', 'username']
# Placeholder for future serializers

View File

@@ -71,15 +71,24 @@ class ChoiceField(Field):
def to_representation(self, obj):
if obj is '':
return None
return {'value': obj, 'label': self._choices[obj]}
data = OrderedDict([
('value', obj),
('label', self._choices[obj])
])
return data
def to_internal_value(self, data):
# Hotwiring boolean values
if hasattr(data, 'lower'):
# Hotwiring boolean values from string
if data.lower() == 'true':
return True
if data.lower() == 'false':
return False
# Check for string representation of an integer (e.g. "123")
try:
data = int(data)
except ValueError:
pass
return data

View File

@@ -1,4 +1,5 @@
import django_filters
from taggit.models import Tag
class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
@@ -19,3 +20,18 @@ class NullableCharFieldFilter(django_filters.CharFilter):
return super(NullableCharFieldFilter, self).filter(qs, value)
qs = self.get_method(qs)(**{'{}__isnull'.format(self.name): True})
return qs.distinct() if self.distinct else qs
class TagFilter(django_filters.ModelMultipleChoiceFilter):
"""
Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
to objects matching all tags.
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('field_name', 'tags__slug')
kwargs.setdefault('to_field_name', 'slug')
kwargs.setdefault('conjoined', True)
kwargs.setdefault('queryset', Tag.objects.all())
super(TagFilter, self).__init__(*args, **kwargs)

View File

@@ -42,6 +42,11 @@ NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
BOOLEAN_WITH_BLANK_CHOICES = (
('', '---------'),
('True', 'Yes'),
('False', 'No'),
)
def parse_numeric_range(string, base=10):
@@ -258,9 +263,21 @@ class APISelect(SelectWithDisabled):
:param api_url: API URL
:param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
:param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
:param url_conditional_append: (Optional) A dict of URL query strings to append to the URL if the
condition is met. The condition is the dict key and is specified in the form `<field_name>__<field_value>`.
If the provided field value is selected for the given field, the URL query string will be appended to
the rendered URL. This is useful in cases where a particular field value dictates an additional API filter.
"""
def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs):
def __init__(
self,
api_url,
display_field=None,
disabled_indicator=None,
url_conditional_append=None,
*args,
**kwargs
):
super(APISelect, self).__init__(*args, **kwargs)
@@ -270,6 +287,9 @@ class APISelect(SelectWithDisabled):
self.attrs['display-field'] = display_field
if disabled_indicator:
self.attrs['disabled-indicator'] = disabled_indicator
if url_conditional_append:
for key, value in url_conditional_append.items():
self.attrs["data-url-conditional-append-{}".format(key)] = value
class APISelectMultiple(APISelect):

View File

@@ -1 +1 @@
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label }}</option>
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label|capfirst }}</option>

View File

@@ -22,6 +22,7 @@ def oneline(value):
"""
return value.replace('\n', ' ')
@register.filter()
def placeholder(value):
"""

View File

@@ -0,0 +1,62 @@
from rest_framework import serializers
from dcim.models import Interface
from utilities.api import WritableNestedSerializer
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
__all__ = [
'NestedClusterGroupSerializer',
'NestedClusterSerializer',
'NestedClusterTypeSerializer',
'NestedInterfaceSerializer',
'NestedVirtualMachineSerializer',
]
#
# Clusters
#
class NestedClusterTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
class Meta:
model = ClusterType
fields = ['id', 'url', 'name', 'slug']
class NestedClusterGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
class Meta:
model = ClusterGroup
fields = ['id', 'url', 'name', 'slug']
class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
#
# Virtual machines
#
class NestedVirtualMachineSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
class Meta:
model = VirtualMachine
fields = ['id', 'url', 'name']
class NestedInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:
model = Interface
fields = ['id', 'url', 'virtual_machine', 'name']

View File

@@ -1,19 +1,21 @@
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
from virtualization.constants import VM_STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from .nested_serializers import *
#
# Cluster types
# Clusters
#
class ClusterTypeSerializer(ValidatedModelSerializer):
@@ -23,18 +25,6 @@ class ClusterTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedClusterTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
class Meta:
model = ClusterType
fields = ['id', 'url', 'name', 'slug']
#
# Cluster groups
#
class ClusterGroupSerializer(ValidatedModelSerializer):
class Meta:
@@ -42,18 +32,6 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
class NestedClusterGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
class Meta:
model = ClusterGroup
fields = ['id', 'url', 'name', 'slug']
#
# Clusters
#
class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True)
@@ -67,45 +45,28 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
]
class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
#
# Virtual machines
#
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
fields = ['id', 'url', 'family', 'address']
class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer(required=False, allow_null=True)
cluster = NestedClusterSerializer()
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = VirtualMachine
fields = [
'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'local_context_data',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields',
'created', 'last_updated',
]
@@ -114,44 +75,27 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'local_context_data',
'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated',
]
def get_config_context(self, obj):
return obj.get_config_context()
class NestedVirtualMachineSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
class Meta:
model = VirtualMachine
fields = ['id', 'url', 'name']
#
# VM interfaces
#
# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
class InterfaceVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
serializer=InterfaceVLANSerializer,
serializer=NestedVLANSerializer,
required=False,
many=True
)
@@ -163,12 +107,3 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags',
]
class NestedInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:
model = Interface
fields = ['id', 'url', 'virtual_machine', 'name']

View File

@@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError
from dcim.models import DeviceRole, Interface, Platform, Region, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -65,9 +65,7 @@ class ClusterFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Cluster
@@ -172,9 +170,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Platform (slug)',
)
tag = django_filters.CharFilter(
field_name='tags__slug',
)
tag = TagFilter()
class Meta:
model = VirtualMachine

View File

@@ -378,6 +378,18 @@ class VirtualMachineTest(APITestCase):
self.assertEqual(virtualmachine4.name, data['name'])
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
def test_create_virtualmachine_without_cluster(self):
data = {
'name': 'Test Virtual Machine 4',
}
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VirtualMachine.objects.count(), 3)
def test_create_virtualmachine_bulk(self):
data = [

View File

@@ -3,7 +3,7 @@ django-cors-headers==2.4.0
django-debug-toolbar==1.10.1
django-filter==2.0.0
django-mptt==0.9.1
django-tables2==2.0.2
django-tables2==2.0.3
django-taggit==0.23.0
django-taggit-serializer==0.1.7
django-timezone-field==3.0
@@ -13,6 +13,6 @@ graphviz==0.10.1
Markdown==2.6.11
netaddr==0.7.19
Pillow==5.3.0
psycopg2-binary==2.7.5
psycopg2-binary==2.7.6.1
py-gfm==0.1.4
pycryptodome==3.7.0
pycryptodome==3.7.1

14
scripts/git-hooks/pre-commit Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# Create a link to this file at .git/hooks/pre-commit to
# force PEP8 validation prior to committing
#
# Ignored violations:
#
# W504: Line break after binary operator
# E501: Line too long
exec 1>&2
echo "Validating PEP8 compliance..."
pycodestyle --ignore=W504,E501 netbox/