mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-31 01:27:45 -06:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0083552d | ||
|
|
869194354c | ||
|
|
aa8c836b94 | ||
|
|
686a65880e | ||
|
|
d3d6c83fbb | ||
|
|
4e3567659a | ||
|
|
7bbf33ee39 | ||
|
|
90e7080b63 | ||
|
|
e6ee26cf0e | ||
|
|
0dcab07519 | ||
|
|
232e6f5076 | ||
|
|
ca0248c3a2 | ||
|
|
a43fc0d3d3 | ||
|
|
6c2a9107dd | ||
|
|
879d879e56 | ||
|
|
c6d048ca51 | ||
|
|
112aaea51f | ||
|
|
c3cdf8e97e | ||
|
|
d2744700c6 | ||
|
|
5d07a5a670 | ||
|
|
f3aef37163 | ||
|
|
90a4b62976 | ||
|
|
7346083b26 | ||
|
|
f052bbc36e | ||
|
|
8d4329197a | ||
|
|
34bfb899d1 | ||
|
|
55c153c5a9 | ||
|
|
3366a6ae3d | ||
|
|
23cde65add | ||
|
|
408f632636 | ||
|
|
83be0b5db4 | ||
|
|
7bed48f5fe | ||
|
|
2fce7ebd8f | ||
|
|
0c33af2140 | ||
|
|
b6a256dc5d | ||
|
|
5785fb6ba2 | ||
|
|
845d467fd9 | ||
|
|
61ca7ee7c2 | ||
|
|
69d829ce8d | ||
|
|
c1838104ae | ||
|
|
c716ca1e87 | ||
|
|
8bad25b860 |
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,3 +1,42 @@
|
||||
v2.4.9 (2018-12-07)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors
|
||||
* [#2495](https://github.com/digitalocean/netbox/issues/2495) - Enable deep-merging of config context data
|
||||
* [#2597](https://github.com/digitalocean/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2400](https://github.com/digitalocean/netbox/issues/2400) - Correct representation of nested object assignment in API docs
|
||||
* [#2576](https://github.com/digitalocean/netbox/issues/2576) - Correct type for count_* fields in site API representation
|
||||
* [#2606](https://github.com/digitalocean/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor
|
||||
* [#2611](https://github.com/digitalocean/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site
|
||||
* [#2613](https://github.com/digitalocean/netbox/issues/2613) - Decrease live search minimum characters to three
|
||||
* [#2615](https://github.com/digitalocean/netbox/issues/2615) - Tweak live search widget to use brief format for API requests
|
||||
* [#2623](https://github.com/digitalocean/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks
|
||||
* [#2634](https://github.com/digitalocean/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
v2.4.7 (2018-11-06)
|
||||
|
||||
## Enhancements
|
||||
|
||||
74
docs/development/extending-models.md
Normal file
74
docs/development/extending-models.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 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 choices to API view
|
||||
|
||||
If the new field has static choices, add it to the `FieldChoicesViewSet` for the app.
|
||||
|
||||
### 7. 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)
|
||||
|
||||
### 8. 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.
|
||||
|
||||
### 9. 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.
|
||||
|
||||
### 10. 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.
|
||||
|
||||
### 11. 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.
|
||||
@@ -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 *`).
|
||||
|
||||
54
docs/development/style-guide.md
Normal file
54
docs/development/style-guide.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 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/
|
||||
```
|
||||
|
||||
## Introducing New Dependencies
|
||||
|
||||
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
|
||||
|
||||
If there's a strong case for introducing a new depdency, it must meet the following criteria:
|
||||
|
||||
* Its complete source code must be published and freely accessible without registration.
|
||||
* Its license must be conducive to inclusion in an open source project.
|
||||
* It must be actively maintained, with no longer than one year between releases.
|
||||
* It must be available via the [Python Package Index](https://pypi.org/) (PyPI).
|
||||
|
||||
When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts.
|
||||
|
||||
## 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`.
|
||||
@@ -246,13 +246,13 @@ At this point, NetBox should be able to run. We can verify this by starting a de
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
June 17, 2016 - 16:17:36
|
||||
Django version 1.9.7, using settings 'netbox.settings'
|
||||
November 28, 2018 - 09:33:45
|
||||
Django version 2.0.9, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
|
||||
|
||||
!!! warning
|
||||
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
|
||||
|
||||
@@ -36,3 +36,9 @@ If using LDAP authentication, install the `django-auth-ldap` package:
|
||||
```no-highlight
|
||||
# pip3 install django-auth-ldap
|
||||
```
|
||||
|
||||
If using Webhooks, install the `django-rq` package:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install django-rq
|
||||
```
|
||||
|
||||
@@ -38,6 +38,7 @@ pages:
|
||||
- Change Logging: 'additional-features/change-logging.md'
|
||||
- Administration:
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
- API:
|
||||
- Overview: 'api/overview.md'
|
||||
- Authentication: 'api/authentication.md'
|
||||
@@ -45,7 +46,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:
|
||||
|
||||
@@ -6,7 +6,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
|
||||
|
||||
@@ -28,9 +28,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
@@ -106,9 +104,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
|
||||
@@ -56,6 +56,11 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneField(required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
count_prefixes = serializers.IntegerField(read_only=True)
|
||||
count_vlans = serializers.IntegerField(read_only=True)
|
||||
count_racks = serializers.IntegerField(read_only=True)
|
||||
count_devices = serializers.IntegerField(read_only=True)
|
||||
count_circuits = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
|
||||
@@ -263,9 +263,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:
|
||||
@@ -309,7 +309,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)
|
||||
|
||||
@@ -76,12 +76,21 @@ IFACE_FF_80211G = 2610
|
||||
IFACE_FF_80211N = 2620
|
||||
IFACE_FF_80211AC = 2630
|
||||
IFACE_FF_80211AD = 2640
|
||||
# SONET
|
||||
IFACE_FF_SONET_OC3 = 6100
|
||||
IFACE_FF_SONET_OC12 = 6200
|
||||
IFACE_FF_SONET_OC48 = 6300
|
||||
IFACE_FF_SONET_OC192 = 6400
|
||||
IFACE_FF_SONET_OC768 = 6500
|
||||
IFACE_FF_SONET_OC1920 = 6600
|
||||
IFACE_FF_SONET_OC3840 = 6700
|
||||
# Fibrechannel
|
||||
IFACE_FF_1GFC_SFP = 3010
|
||||
IFACE_FF_2GFC_SFP = 3020
|
||||
IFACE_FF_4GFC_SFP = 3040
|
||||
IFACE_FF_8GFC_SFP_PLUS = 3080
|
||||
IFACE_FF_16GFC_SFP_PLUS = 3160
|
||||
IFACE_FF_32GFC_SFP28 = 3320
|
||||
# Serial
|
||||
IFACE_FF_T1 = 4000
|
||||
IFACE_FF_E1 = 4010
|
||||
@@ -146,6 +155,18 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_80211AD, 'IEEE 802.11ad'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'SONET',
|
||||
[
|
||||
[IFACE_FF_SONET_OC3, 'OC-3/STM-1'],
|
||||
[IFACE_FF_SONET_OC12, 'OC-12/STM-4'],
|
||||
[IFACE_FF_SONET_OC48, 'OC-48/STM-16'],
|
||||
[IFACE_FF_SONET_OC192, 'OC-192/STM-64'],
|
||||
[IFACE_FF_SONET_OC768, 'OC-768/STM-256'],
|
||||
[IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'],
|
||||
[IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'FibreChannel',
|
||||
[
|
||||
@@ -154,6 +175,7 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
|
||||
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
|
||||
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
|
||||
[IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
|
||||
@@ -9,11 +9,11 @@ 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 (
|
||||
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
|
||||
WIRELESS_IFACE_TYPES,
|
||||
WIRELESS_IFACE_TYPES, IFACE_FF_CHOICES,
|
||||
)
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
@@ -83,9 +83,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
@@ -196,9 +194,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
@@ -306,9 +302,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
@@ -530,9 +524,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
label='Virtual chassis (ID)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -592,9 +584,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
|
||||
class ConsolePortFilter(DeviceComponentFilterSet):
|
||||
@@ -653,9 +643,7 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='_mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label='Assigned VLAN'
|
||||
@@ -664,10 +652,14 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='filter_vlan',
|
||||
label='Assigned VID'
|
||||
)
|
||||
form_factor = django_filters.MultipleChoiceFilter(
|
||||
choices=IFACE_FF_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
|
||||
fields = ['name', 'enabled', 'mtu', 'mgmt_only']
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
@@ -797,9 +789,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
|
||||
@@ -20,7 +20,7 @@ from utilities.forms import (
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||
FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .constants import (
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
|
||||
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
|
||||
@@ -820,6 +820,23 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
display_field='model'
|
||||
)
|
||||
)
|
||||
cluster_group = forms.ModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'cluster', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
cluster = ChainedModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
chains=(
|
||||
('group', 'cluster_group'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}',
|
||||
)
|
||||
)
|
||||
comments = CommentField()
|
||||
tags = TagField(required=False)
|
||||
local_context_data = JSONField(required=False)
|
||||
@@ -828,8 +845,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
model = Device
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
|
||||
'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
|
||||
'local_context_data'
|
||||
'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant',
|
||||
'comments', 'tags', 'local_context_data'
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
|
||||
@@ -19,11 +19,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
|
||||
Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, Region, Site, VirtualChassis,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
|
||||
REGION_LINK = """
|
||||
@@ -250,7 +250,7 @@ class RackRoleTable(BaseTable):
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
model = RackRole
|
||||
fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
|
||||
|
||||
|
||||
|
||||
@@ -858,8 +858,10 @@ class DeviceView(View):
|
||||
device.device_type.interface_ordering
|
||||
).select_related(
|
||||
'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
|
||||
'circuit_termination__circuit'
|
||||
).prefetch_related('ip_addresses')
|
||||
'circuit_termination__circuit__provider'
|
||||
).prefetch_related(
|
||||
'tags', 'ip_addresses'
|
||||
)
|
||||
|
||||
# Device bays
|
||||
device_bays = natsorted(
|
||||
|
||||
@@ -13,8 +13,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,
|
||||
@@ -208,6 +208,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
|
||||
#
|
||||
@@ -227,6 +232,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,
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
# Generated by Django 1.11.14 on 2018-07-31 02:19
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import connection, migrations, models
|
||||
@@ -19,13 +16,14 @@ def verify_postgresql_version(apps, schema_editor):
|
||||
"""
|
||||
Verify that PostgreSQL is version 9.4 or higher.
|
||||
"""
|
||||
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
|
||||
DB_MINIMUM_VERSION = 90400 # 9.4.0
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
row = cursor.fetchone()
|
||||
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||
pg_version = connection.pg_version
|
||||
|
||||
if pg_version < DB_MINIMUM_VERSION:
|
||||
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
|
||||
|
||||
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
|
||||
except OperationalError:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-26 21:25
|
||||
from __future__ import unicode_literals
|
||||
from distutils.version import StrictVersion
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
@@ -15,13 +13,14 @@ def verify_postgresql_version(apps, schema_editor):
|
||||
"""
|
||||
Verify that PostgreSQL is version 9.4 or higher.
|
||||
"""
|
||||
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
|
||||
DB_MINIMUM_VERSION = 90400 # 9.4.0
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
row = cursor.fetchone()
|
||||
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||
pg_version = connection.pg_version
|
||||
|
||||
if pg_version < DB_MINIMUM_VERSION:
|
||||
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
|
||||
|
||||
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
|
||||
except OperationalError:
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||
from utilities.utils import foreground_color
|
||||
from utilities.utils import deepmerge, foreground_color
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
|
||||
@@ -727,11 +727,11 @@ class ConfigContextModel(models.Model):
|
||||
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
|
||||
data = OrderedDict()
|
||||
for context in ConfigContext.objects.get_for_object(self):
|
||||
data.update(context.data)
|
||||
data = deepmerge(data, context.data)
|
||||
|
||||
# If the object has local config context data defined, that data overwrites all rendered data
|
||||
# If the object has local config context data defined, merge it last
|
||||
if self.local_context_data is not None:
|
||||
data.update(self.local_context_data)
|
||||
data = deepmerge(data, self.local_context_data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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
|
||||
@@ -15,6 +16,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>
|
||||
@@ -55,6 +64,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'}},
|
||||
@@ -66,6 +79,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()
|
||||
|
||||
@@ -9,6 +9,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'),
|
||||
@@ -16,6 +17,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'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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
|
||||
@@ -9,15 +10,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
|
||||
|
||||
|
||||
#
|
||||
@@ -25,11 +31,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 = {
|
||||
'klass': 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
|
||||
@@ -45,7 +85,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'
|
||||
|
||||
@@ -85,6 +129,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
|
||||
|
||||
@@ -45,7 +45,7 @@ def enqueue_webhooks(instance, action):
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
webhook,
|
||||
serializer.data,
|
||||
instance.__class__,
|
||||
instance._meta.model_name,
|
||||
action,
|
||||
str(datetime.datetime.now())
|
||||
)
|
||||
|
||||
@@ -10,14 +10,14 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, data, model_class, event, timestamp):
|
||||
def process_webhook(webhook, data, model_name, event, timestamp):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
payload = {
|
||||
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
|
||||
'timestamp': timestamp,
|
||||
'model': model_class._meta.model_name,
|
||||
'model': model_name,
|
||||
'data': data
|
||||
}
|
||||
headers = {
|
||||
|
||||
@@ -9,7 +9,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
|
||||
@@ -31,9 +31,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -73,9 +71,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='RIR (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
@@ -174,9 +170,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
choices=PREFIX_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
@@ -303,9 +297,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
role = django_filters.MultipleChoiceFilter(
|
||||
choices=IPADDRESS_ROLE_CHOICES
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
@@ -422,9 +414,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
choices=VLAN_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
@@ -466,9 +456,7 @@ class ServiceFilter(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Virtual machine (name)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
|
||||
@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.4.7'
|
||||
VERSION = '2.4.9'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -275,9 +275,12 @@ RQ_QUEUES = {
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
||||
'utilities.custom_inspectors.TagListFieldInspector',
|
||||
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
|
||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||
'drf_yasg.inspectors.RelatedFieldInspector',
|
||||
|
||||
@@ -197,7 +197,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]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,7 @@ $(document).ready(function() {
|
||||
source: function(request, response) {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: search_field.attr('data-source'),
|
||||
url: search_field.attr('data-source') + '?brief=1',
|
||||
data: search_key + '=' + request.term,
|
||||
success: function(data) {
|
||||
var choices = [];
|
||||
@@ -49,7 +49,7 @@ $(document).ready(function() {
|
||||
// Disable parent selection fields
|
||||
// $('select[filter-for="' + real_field.attr('name') + '"]').val('');
|
||||
},
|
||||
minLength: 4,
|
||||
minLength: 3,
|
||||
delay: 500
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,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
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
@@ -392,6 +393,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)
|
||||
@@ -399,12 +401,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):
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
import string
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from django.conf import settings
|
||||
@@ -88,7 +89,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)
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> ·
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> ·
|
||||
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
|
||||
|
||||
@@ -131,7 +131,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 %}
|
||||
|
||||
@@ -129,7 +129,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 %}
|
||||
|
||||
@@ -290,7 +290,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 %}
|
||||
@@ -525,6 +525,7 @@
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>Mode</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Virtualization</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tenancy</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model Name</td>
|
||||
<td>{{ devicetype.model }}</td>
|
||||
<td>
|
||||
{{ devicetype.model }}<br/>
|
||||
<small class="text-muted">{{ devicetype.slug }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Part Number</td>
|
||||
@@ -164,7 +167,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 %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
|
||||
{% load helpers %}
|
||||
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
|
||||
|
||||
{# Checkbox #}
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
@@ -7,32 +8,53 @@
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{# Icon and name #}
|
||||
{# Icon/name/MAC #}
|
||||
<td>
|
||||
<span title="{{ iface.get_form_factor_display }}">
|
||||
<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:"—" }}</td>
|
||||
{# Description/tags #}
|
||||
<td>
|
||||
{% if iface.description %}
|
||||
{{ iface.description }}<br/>
|
||||
{% endif %}
|
||||
{% for tag in iface.tags.all %}
|
||||
{% tag tag %}
|
||||
{% empty %}
|
||||
{% if not iface.description %}—{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
||||
{# MTU #}
|
||||
<td>{{ iface.mtu|default:"—" }}</td>
|
||||
|
||||
{# 802.1Q mode #}
|
||||
<td>{{ iface.get_mode_display }}</td>
|
||||
<td>{{ iface.get_mode_display|default:"—" }}</td>
|
||||
|
||||
{# Connection or type #}
|
||||
{% 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>
|
||||
@@ -138,7 +160,7 @@
|
||||
{% endif %}
|
||||
|
||||
{# IP addresses table #}
|
||||
<td colspan="7" style="padding: 0">
|
||||
<td colspan="8" style="padding: 0">
|
||||
<table class="table table-condensed interface-ips">
|
||||
<thead>
|
||||
<tr class="text-muted">
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
{% ifequal u.device.face face_id %}
|
||||
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
|
||||
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
|
||||
{{ u.device.name|default:u.device.device_role }}
|
||||
{{ u.device }}
|
||||
{% if u.device.devicebay_count %}
|
||||
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ u.device.name|default:u.device.device_role }}</span>
|
||||
<span>{{ u.device }}</span>
|
||||
{% endifequal %}
|
||||
</li>
|
||||
{% else %}
|
||||
|
||||
@@ -164,7 +164,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 %}
|
||||
|
||||
@@ -230,7 +230,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 %}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
69
netbox/templates/extras/tag.html
Normal file
69
netbox/templates/extras/tag.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -87,7 +87,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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -4,7 +4,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
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Group (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
|
||||
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
from taggit_serializer.serializers import TagListSerializerField
|
||||
|
||||
from extras.api.customfields import CustomFieldsSerializer
|
||||
from utilities.api import ChoiceField
|
||||
from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
|
||||
|
||||
|
||||
class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
|
||||
def get_request_serializer(self):
|
||||
serializer = super().get_request_serializer()
|
||||
|
||||
if serializer is not None and self.method in self.implicit_body_methods:
|
||||
properties = {}
|
||||
for child_name, child in serializer.fields.items():
|
||||
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
|
||||
properties[child_name] = None
|
||||
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
|
||||
properties[child_name] = None
|
||||
|
||||
if properties:
|
||||
writable_class = type('Writable' + type(serializer).__name__, (type(serializer),), properties)
|
||||
serializer = writable_class()
|
||||
|
||||
return serializer
|
||||
|
||||
|
||||
class SerializedPKRelatedFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
if isinstance(field, SerializedPKRelatedField):
|
||||
return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references)
|
||||
|
||||
return NotHandled
|
||||
|
||||
|
||||
class TagListFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
if isinstance(field, TagListSerializerField):
|
||||
child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references)
|
||||
return SwaggerType(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=child_schema,
|
||||
)
|
||||
|
||||
return NotHandled
|
||||
|
||||
|
||||
class CustomChoiceFieldInspector(FieldInspector):
|
||||
|
||||
@@ -5,6 +5,7 @@ import itertools
|
||||
import django_filters
|
||||
from django import forms
|
||||
from django.utils.encoding import force_text
|
||||
from taggit.models import Tag
|
||||
|
||||
|
||||
#
|
||||
@@ -68,3 +69,18 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
stripped_value = value
|
||||
super(NullableModelMultipleChoiceField, self).clean(stripped_value)
|
||||
return value
|
||||
|
||||
|
||||
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('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)
|
||||
|
||||
89
netbox/utilities/tests/test_utils.py
Normal file
89
netbox/utilities/tests/test_utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
class DeepMergeTest(TestCase):
|
||||
"""
|
||||
Validate the behavior of the deepmerge() utility.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
return
|
||||
|
||||
def test_deepmerge(self):
|
||||
|
||||
dict1 = {
|
||||
'active': True,
|
||||
'foo': 123,
|
||||
'fruits': {
|
||||
'orange': 1,
|
||||
'apple': 2,
|
||||
'pear': 3,
|
||||
},
|
||||
'vegetables': None,
|
||||
'dairy': {
|
||||
'milk': 1,
|
||||
'cheese': 2,
|
||||
},
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 10,
|
||||
'b': 20,
|
||||
'c': 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dict2 = {
|
||||
'active': False,
|
||||
'bar': 456,
|
||||
'fruits': {
|
||||
'banana': 4,
|
||||
'grape': 5,
|
||||
},
|
||||
'vegetables': {
|
||||
'celery': 1,
|
||||
'carrots': 2,
|
||||
'corn': 3,
|
||||
},
|
||||
'dairy': None,
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 100,
|
||||
'd': 40,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged = {
|
||||
'active': False,
|
||||
'foo': 123,
|
||||
'bar': 456,
|
||||
'fruits': {
|
||||
'orange': 1,
|
||||
'apple': 2,
|
||||
'pear': 3,
|
||||
'banana': 4,
|
||||
'grape': 5,
|
||||
},
|
||||
'vegetables': {
|
||||
'celery': 1,
|
||||
'carrots': 2,
|
||||
'corn': 3,
|
||||
},
|
||||
'dairy': None,
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 100,
|
||||
'b': 20,
|
||||
'c': 30,
|
||||
'd': 40,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
deepmerge(dict1, dict2),
|
||||
merged
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import OrderedDict
|
||||
import datetime
|
||||
import json
|
||||
import six
|
||||
@@ -109,3 +110,16 @@ def serialize_object(obj, extra=None):
|
||||
data.update(extra)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def deepmerge(original, new):
|
||||
"""
|
||||
Deep merge two dictionaries (new into original) and return a new dict
|
||||
"""
|
||||
merged = OrderedDict(original)
|
||||
for key, val in new.items():
|
||||
if key in original and isinstance(original[key], dict) and isinstance(val, dict):
|
||||
merged[key] = deepmerge(original[key], val)
|
||||
else:
|
||||
merged[key] = val
|
||||
return merged
|
||||
|
||||
@@ -93,7 +93,7 @@ class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
|
||||
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)
|
||||
|
||||
@@ -9,7 +9,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
|
||||
|
||||
@@ -64,9 +64,7 @@ class ClusterFilter(CustomFieldFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
@@ -168,9 +166,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Platform (slug)',
|
||||
)
|
||||
tag = django_filters.CharFilter(
|
||||
name='tags__slug',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
|
||||
@@ -380,6 +380,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 = [
|
||||
|
||||
14
scripts/git-hooks/pre-commit
Executable file
14
scripts/git-hooks/pre-commit
Executable 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/
|
||||
|
||||
Reference in New Issue
Block a user