Compare commits

...

76 Commits

Author SHA1 Message Date
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
585e08eb95 Release v1.8.4 2017-02-03 13:55:32 -05:00
Jeremy Stretch
d817990283 Fixes #865: Fix server error when attempting to delete a protected object parent (Python 3) 2017-02-01 12:09:59 -05:00
Jeremy Stretch
9905099a71 Fixes #854: Check whether object still exists before attempting to resolve its URL 2017-02-01 11:59:47 -05:00
Jeremy Stretch
0eba5a0de3 Fixes #851: Resolve encoding issues during import/export with Python 3 2017-02-01 11:49:54 -05:00
Jeremy Stretch
5eb3c1a67b Removed deprecated base_path Swagger setting 2017-02-01 10:48:36 -05:00
Jeremy Stretch
b370375414 Fixes #861: Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses 2017-01-31 17:25:44 -05:00
Jeremy Stretch
8536f6c163 Closes #856: Strip whitespace from fields during CSV import 2017-01-31 16:54:13 -05:00
Jeremy Stretch
f4f41a5985 Fixes #859: Fix Javascript for connection status toggle button 2017-01-31 09:41:25 -05:00
Jeremy Stretch
af3c9eaec1 Fixes #854: Correct processing of get_return_url() in ObjectDeleteView 2017-01-30 12:13:24 -05:00
Jeremy Stretch
b8b2ea7ccb Post-release version bump 2017-01-26 14:00:08 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b2ef7bb104 Release v1.8.3 2017-01-26 13:57:00 -05:00
Jeremy Stretch
5d5d4ac714 Fixes #845: Fix missing edit/delete buttons on object tables for non-superusers 2017-01-26 13:20:56 -05:00
dav3860
b3b96e5e10 Support for comma in interfaces and ip addresses bulk creation (#833)
* Added support for comma in interfaces and ip addresses bulk creation

* fixed PEP8 style

* removed unnecessary assertions
2017-01-25 14:47:14 -05:00
Jeremy Stretch
6be520a8f9 Fixed DeviceTypeTest 2017-01-25 14:38:45 -05:00
Jeremy Stretch
f3db914e9d Fixes #844: Apply order_naturally() to API interfaces list 2017-01-25 14:34:34 -05:00
Jeremy Stretch
fbfa3cf619 Added gunicorn_config.py to .gitignore 2017-01-24 13:41:46 -05:00
Jeremy Stretch
1317c0dd8c Closes #841: Merged search and filter forms on all object lists 2017-01-24 12:05:39 -05:00
Jeremy Stretch
bbc633b004 Closes #782: Allow filtering devices list by manufacturer 2017-01-24 10:53:59 -05:00
Jeremy Stretch
ed8fdd9292 Fixes #816: Redirect back to parent prefix view after deleting child prefixes 2017-01-24 09:50:51 -05:00
Jeremy Stretch
2d9c33c34f Tweaked installation docs to include instructions for Python 2 and 3 2017-01-23 17:01:23 -05:00
Jens L
80439c495e Basic Support for Python 3 (#827)
* Rudimentary python3 support

* update docs and trigger Travis

* fix some of the tests

* fix all python3 errors

* change env calls to just python

* add @python_2_unicode_compatible decorator to models for python2 compatibility

* switch netbox.configuration to from netbox import configuration
2017-01-23 16:44:29 -05:00
Jeremy Stretch
1bddd038fe Fixes #840: Correct API path resolution for secrets when BASE_PATH is configured 2017-01-23 16:25:05 -05:00
Jeremy Stretch
d36923e47d Fixes #817: Update last_updated time of a circuit when editing a child termination 2017-01-23 15:31:41 -05:00
Jeremy Stretch
476cbf17f6 Closes #820: Add VLAN column to parent prefixes table on IP address view 2017-01-23 14:23:42 -05:00
Jeremy Stretch
91d50b9627 Closes #836: Add 'deprecated' status for IP addresses 2017-01-23 14:12:43 -05:00
Jeremy Stretch
52420945b2 Standardized naming of return_url for all object views 2017-01-23 14:07:26 -05:00
Jeremy Stretch
b70eca7661 Fixes #830: Redirect user to device view after editing a device component 2017-01-23 12:14:12 -05:00
Jeremy Stretch
39d083eae7 Re-implemented method for bulk editing/deleting all objects within a filtered queryset 2017-01-20 16:42:11 -05:00
Jeremy Stretch
3bfc1ebcea Post-release version bump 2017-01-18 16:23:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
6121f97ca9 Release v1.8.2 2017-01-18 16:19:45 -05:00
Jeremy Stretch
74e48fc490 PEP8 fixes 2017-01-18 14:43:46 -05:00
Jeremy Stretch
28a9307f9f Deprecated use_obj_view in favor of get_return_url() 2017-01-18 14:34:17 -05:00
Jeremy Stretch
cdccc3a47f Ditched get_parent_url() model method in favor of overrideable get_return_url() view method 2017-01-18 14:07:46 -05:00
Jeremy Stretch
3eb969de0c Standardized the use of return_url for ObjectDeleteView 2017-01-18 13:30:19 -05:00
Jeremy Stretch
9ff59ab686 Closes #760: Redirect user back to device view after deleting an assigned IP address 2017-01-18 12:25:07 -05:00
Jeremy Stretch
fc7f88d2a2 Regression fix: order_naturally() must come first in the queryset definition 2017-01-18 11:55:48 -05:00
Jeremy Stretch
769537fe98 Fixes #810: Suppress unique IP validation on invalid IP addresses and prefixes 2017-01-18 09:55:57 -05:00
Jeremy Stretch
f8a4f1b24f Closes #797: Add description column to VLANs table 2017-01-17 16:06:19 -05:00
Jeremy Stretch
7f3b358571 Fixes #807: Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked 2017-01-17 15:46:43 -05:00
Jeremy Stretch
c264281530 Add an empty label (global) to IPAddressBulkAddForm VRF field 2017-01-17 15:33:55 -05:00
Jeremy Stretch
b3f20aa233 Closes #783: Add a description field to the Circuit model 2017-01-17 15:18:03 -05:00
Jeremy Stretch
07997b24ca Fixes #785: Trigger validation error when importing a prefix assigned to a nonexistent VLAN 2017-01-17 15:01:30 -05:00
Jeremy Stretch
03859d7287 Closes #803: Clarify that no child objects are deleted when deleting a prefix 2017-01-17 14:52:39 -05:00
Jeremy Stretch
0ad2670822 Closes #805: Linkify site column in device table 2017-01-17 14:46:29 -05:00
Jeremy Stretch
ab706d2440 Follow-up to #804 2017-01-17 12:32:54 -05:00
Jeremy Stretch
398faf518c Merge pull request #804 from digitalocean/prefix-unique
Enforce Global Unique on Prefixes
2017-01-17 10:28:00 -05:00
Zach Moody
edf29e7b9b moved duplicates() method to model instead of manager. 2017-01-16 18:14:34 -06:00
Zach Moody
485a21f13e cleaned up IPAddress clean() to be more like Prefix's 2017-01-16 16:52:03 -06:00
Zach Moody
eedec192ba Added model tests for duplicate prefix and IPs. 2017-01-16 16:40:06 -06:00
Zach Moody
cfaf8b9157 added duplicates() method to IPAddress and Prefix model managers.
refactored condition on IPAddress and Prefix clean method to use new
manager method.
2017-01-16 16:28:04 -06:00
Jeremy Stretch
98e2145b52 Merge pull request #796 from rburkholder/rburkholder-patch-TenantSerializer-description
rburkholder - patch - tenant serialiser - add description field to output
2017-01-13 09:36:33 -05:00
Jeremy Stretch
466c505bb8 Corrected PEP8 errors 2017-01-13 09:30:59 -05:00
Raymond P. Burkholder
97c0f23c67 Add description field to TenantSerializer
This might be just an oversight.  Other data models do include the description in their serialisers.  The API produces the description field with this change.
2017-01-13 08:49:43 -04:00
Jeremy Stretch
424c2a59d6 Table rendering optimizations 2017-01-06 16:50:57 -05:00
Jeremy Stretch
c9e7c12463 Closes #284: Added interface_ordering field to DeviceType 2017-01-06 12:59:49 -05:00
Jeremy Stretch
2ef1e623a3 Merge pull request #781 from zevlag/patch-1
Update CONTRIBUTING.md to replace Reddit with Google Groups
2017-01-05 21:55:16 -05:00
Josh Galvez
1486a8901a Update CONTRIBUTING.md
Change Reddit to Google Groups Mailing List
2017-01-05 16:02:05 -07:00
Jeremy Stretch
73ae87aa57 Updated circuits documentation 2017-01-05 17:06:30 -05:00
Jeremy Stretch
ac72e90dcc Fixes #778: Refactored order_interfaces() to fix InterfaceTemplate ordering within a table 2017-01-05 16:12:07 -05:00
Jeremy Stretch
dbf9840b26 Corrected permissions for device component form rendering 2017-01-05 15:37:15 -05:00
Jeremy Stretch
09fe328c3f Standardized template names 2017-01-05 15:31:41 -05:00
Jeremy Stretch
381eb664cf Added alternative installations section 2017-01-04 17:16:04 -05:00
Jeremy Stretch
23c6451524 Fixes #776: Prevent circuits from appearing twice while searching 2017-01-04 16:56:28 -05:00
Jeremy Stretch
99cd78cbbf Post-release version bump 2017-01-04 15:31:10 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
bce23ebdf5 Release v1.8.1 2017-01-04 15:28:53 -05:00
Jeremy Stretch
0d4b2a6e92 Fixes #772: Fix TypeError in API RackUnitListView when no device is excluded 2017-01-04 10:58:11 -05:00
Jeremy Stretch
52567c4ade Fixes #764: Encapsulate in double quotes values containing commas when exporting to CSV 2017-01-04 10:47:00 -05:00
Jeremy Stretch
8154ae3685 Closes #771: Don't automatically redirect user when only one object is returned in a list 2017-01-04 09:51:40 -05:00
Jeremy Stretch
7f297b4733 Fixes #769: Show default value for boolean custom fields 2017-01-04 09:47:26 -05:00
Jeremy Stretch
96451bfe9e Fixes #767: Fixes xconnect_id error when searching for ciruits 2017-01-03 17:00:43 -05:00
Jeremy Stretch
921b08d0c9 Allow null filtering of prefix VLAN 2017-01-03 16:57:42 -05:00
Jeremy Stretch
6eff95a2b1 Post-release version bump 2017-01-03 15:14:09 -05:00
129 changed files with 1195 additions and 816 deletions

2
.gitignore vendored
View File

@@ -1,8 +1,10 @@
*.pyc
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/static
.idea
/*.sh
!upgrade.sh
fabfile.py
*.swp
gunicorn_config.py

View File

@@ -9,6 +9,9 @@ env:
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
install:
- pip install -r requirements.txt
- pip install pep8

View File

@@ -8,10 +8,9 @@ If you encounter any issues installing or using NetBox, try one of the following
Join the #netbox channel on [Freenode IRC](https://freenode.net/). You can connect to Freenode at irc.freenode.net using
an IRC client, or you can use their [webchat client](https://webchat.freenode.net/).
### Reddit
### Mailing List
We have established [/r/netbox](https://www.reddit.com/r/netbox) on Reddit for NetBox issues and general discussion.
Reddit registration is free and does not require providing an email address (although it is encouraged).
We have established a Google Groups Mailing List for issues and general discussion. You can find us [here]( https://groups.google.com/forum/#!forum/netbox-discuss).
## Reporting Bugs
@@ -24,7 +23,7 @@ click "add a reaction" in the top right corner of the issue and add a thumbs up
comment describing how it's affecting your installation. This will allow us to prioritize bugs based on how many users
are affected.
* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Reddit.
* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Google Groups.
**Do not** file an issue until you have received confirmation that it is in fact a bug. Invalid issues are very
distracting and slow the pace at which NetBox is developed.

View File

@@ -4,7 +4,7 @@ NetBox is an IP address management (IPAM) and data center infrastructure managem
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
@@ -25,6 +25,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https
# Installation
Please see [the documentation](http://netbox.readthedocs.io/en/latest/) for instructions on installing NetBox.
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
## Alternative Installations
* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))

View File

@@ -4,29 +4,30 @@ The circuits component of NetBox deals with the management of long-haul Internet
A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
Each provider may be assigned an autonomous system number (ASN) for reference. Each provider can also be assigned account and contact information, as well as miscellaneous comments.
Each provider may be assigned an autonomous system number (ASN), an account number, and contact information.
---
# Circuits
A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned circuit ID which is unique to that provider. Each circuit must also be assigned to a site, and may optionally be connected to a specific interface on a specific device within that site.
NetBox also tracks miscellaneous circuit attributes (most of which are optional), including:
* Date of installation
* Port speed
* Commit rate
* Cross-connect ID
* Patch panel information
A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider.
### Circuit Types
Circuits can be classified by type. For example:
Circuits are classified by type. For example:
* Internet transit
* Out-of-band connectivity
* Peering
* Private backhaul
Each circuit must be assigned exactly one circuit type.
Circuit types are fully customizable.
### Circuit Terminations
A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
Each circuit termination can be tied to a site, or to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
!!! note
A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit.

View File

@@ -2,12 +2,29 @@
**Debian/Ubuntu**
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
Python 2:
```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
**CentOS/RHEL**
Python 3:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
```
Python 2:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel

View File

@@ -0,0 +1 @@
default_app_config = 'circuits.apps.CircuitsConfig'

View File

@@ -62,7 +62,8 @@ class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'terminations', 'custom_fields']
class CircuitNestedSerializer(CircuitSerializer):

9
netbox/circuits/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class CircuitsConfig(AppConfig):
name = "circuits"
verbose_name = "Circuits"
def ready(self):
import circuits.signals

View File

@@ -96,6 +96,8 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
def search(self, queryset, value):
return queryset.filter(
Q(cid__icontains=value) |
Q(xconnect_id__icontains=value) |
Q(terminations__xconnect_id__icontains=value) |
Q(terminations__pp_info__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
).distinct()

View File

@@ -62,6 +62,7 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
@@ -86,7 +87,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
@@ -104,7 +105,7 @@ class CircuitFromCSVForm(forms.ModelForm):
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
@@ -117,14 +118,16 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
description = forms.CharField(max_length=100, required=False)
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['tenant', 'commit_rate', 'comments']
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
q = forms.CharField(required=False, label='Search')
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-17 20:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0006_terminations'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='description',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@@ -1,10 +1,12 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.utils import csv_format
from utilities.models import CreatedUpdatedModel
@@ -32,6 +34,7 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
@python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -50,25 +53,26 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:provider', args=[self.slug])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.slug,
str(self.asn) if self.asn else '',
self.asn,
self.account,
self.portal_url,
])
@python_2_unicode_compatible
class CircuitType(models.Model):
"""
Circuits can be orgnanized by their functional role. For example, a user might wish to define CircuitTypes named
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
name = models.CharField(max_length=50, unique=True)
@@ -77,13 +81,14 @@ class CircuitType(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
@python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
@@ -96,6 +101,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
description = models.CharField(max_length=100, blank=True)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -103,20 +109,21 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.provider, self.cid)
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
def to_csv(self):
return ','.join([
return csv_format([
self.cid,
self.provider.name,
self.type.name,
self.tenant.name if self.tenant else '',
self.install_date.isoformat() if self.install_date else '',
str(self.commit_rate) if self.commit_rate else '',
self.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None,
self.commit_rate,
self.description,
])
def _get_termination(self, side):
@@ -138,6 +145,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
commit_rate_human.admin_order_field = 'commit_rate'
@python_2_unicode_compatible
class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
@@ -153,12 +161,9 @@ class CircuitTermination(models.Model):
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
def __unicode__(self):
def __str__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
def get_parent_url(self):
return self.circuit.get_absolute_url()
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:

View File

@@ -0,0 +1,13 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Circuit, CircuitTermination
@receiver((post_save, post_delete), sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
"""
Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now())

View File

@@ -60,9 +60,8 @@ class CircuitTable(BaseTable):
args=[Accessor('termination_a.site.slug')])
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')])
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
verbose_name='Commit Rate')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

@@ -1,6 +1,7 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
@@ -24,7 +25,6 @@ class ProviderListView(ObjectListView):
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'
@@ -46,13 +46,13 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -60,21 +60,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ProviderImportForm
table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
filter = filters.ProviderFilter
form = forms.ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
default_redirect_url = 'circuits:provider_list'
filter = filters.ProviderFilter
default_return_url = 'circuits:provider_list'
#
@@ -84,7 +86,6 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
template_name = 'circuits/circuittype_list.html'
@@ -92,14 +93,15 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
obj_list_url = 'circuits:circuittype_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('circuits:circuittype_list')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
default_redirect_url = 'circuits:circuittype_list'
default_return_url = 'circuits:circuittype_list'
#
@@ -111,7 +113,6 @@ class CircuitListView(ObjectListView):
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
table = tables.CircuitTable
edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
template_name = 'circuits/circuit_list.html'
@@ -134,13 +135,13 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.CircuitForm
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -148,21 +149,23 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.CircuitImportForm
table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
filter = filters.CircuitFilter
form = forms.CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
default_redirect_url = 'circuits:circuit_list'
filter = filters.CircuitFilter
default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
@@ -206,7 +209,7 @@ def circuit_terminations_swap(request, pk):
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'cancel_url': circuit.get_absolute_url(),
'return_url': circuit.get_absolute_url(),
})
@@ -223,10 +226,12 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, args, kwargs):
if 'circuit' in kwargs:
circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
obj.circuit = circuit
obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
return obj
def get_return_url(self, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'

View File

@@ -134,11 +134,13 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
manufacturer = ManufacturerNestedSerializer()
subdevice_role = serializers.SerializerMethodField()
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
class Meta:
model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
'comments', 'custom_fields', 'instance_count']
def get_subdevice_role(self, obj):
return {
@@ -198,9 +200,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates',
'interface_templates']
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
'power_outlet_templates', 'interface_templates']
#

View File

@@ -118,11 +118,13 @@ class RackUnitListView(APIView):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
try:
exclude = int(request.GET.get('exclude', None))
except ValueError:
exclude = None
elevation = rack.get_rack_units(face, exclude)
exclude_pk = request.GET.get('exclude', None)
if exclude_pk is not None:
try:
exclude_pk = int(exclude_pk)
except ValueError:
exclude_pk = None
elevation = rack.get_rack_units(face, exclude_pk)
# Serialize Devices within the rack elevation
for u in elevation:
@@ -329,7 +331,8 @@ class InterfaceListView(generics.ListAPIView):
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
# Filter by type (physical or virtual)
iface_type = self.request.query_params.get('type')
@@ -487,8 +490,8 @@ class RelatedConnectionsView(APIView):
response['power-ports'].append(data)
# Interface connections
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
'circuit_termination')
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
for iface in interfaces:
data = serializers.InterfaceDetailSerializer(instance=iface).data
del(data['device'])

View File

@@ -13,13 +13,13 @@ from utilities.forms import (
SlugField,
)
from formfields import MACAddressFormField
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -101,6 +101,7 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
q = forms.CharField(required=False, label='Search')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
null_option=(0, 'None'))
@@ -232,6 +233,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
@@ -263,13 +265,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'comments']
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
labels = {
'interface_ordering': 'Order interfaces by',
}
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False)
class Meta:
nullable_fields = []
@@ -277,6 +283,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = DeviceType
q = forms.CharField(required=False, label='Search')
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
@@ -635,18 +642,46 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
label='Rack Group')
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'))
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
.annotate(filter_count=Count('instances')), label='Type')
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug', null_option=(0, 'None'))
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
mac_address = forms.CharField(required=False, label='MAC address')
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
to_field_name='slug',
)
rack_group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
label='Rack Group',
)
role = FilterChoiceField(
queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'),
)
manufacturer_id = FilterChoiceField(
queryset=Manufacturer.objects.all(),
label='Manufacturer',
)
device_type_id = FilterChoiceField(
queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
filter_count=Count('instances'),
),
label='Model',
)
platform = FilterChoiceField(
queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
null_option=(0, 'None'),
)
status = forms.NullBooleanField(
required=False,
widget=forms.Select(choices=FORM_STATUS_CHOICES),
)
mac_address = forms.CharField(
required=False,
label='MAC address',
)
#

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-06 16:56
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0024_site_add_contact_fields'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='interface_ordering',
field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1),
),
]

View File

@@ -8,6 +8,7 @@ from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.utils.encoding import python_2_unicode_compatible
from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
@@ -16,6 +17,7 @@ from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .fields import ASNField, MACAddressField
@@ -55,6 +57,13 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
IFACE_ORDERING_POSITION = 1
IFACE_ORDERING_NAME = 2
IFACE_ORDERING_CHOICES = [
[IFACE_ORDERING_POSITION, 'Slot/position'],
[IFACE_ORDERING_NAME, 'Name (alphabetically)']
]
# Virtual
IFACE_FF_VIRTUAL = 0
# Ethernet
@@ -181,48 +190,6 @@ RPC_CLIENT_CHOICES = [
]
def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
"""
Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
following pattern:
{a}/{b}/{c}:{d}
Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
None. 'None' is ordered after all other values. For example:
et-0/0/0
et-0/0/1
et-0/1/0
xe-0/1/1:0
xe-0/1/1:1
xe-0/1/1:2
xe-0/1/1:3
et-0/1/2
...
et-0/1/9
et-0/1/10
et-0/1/11
et-1/0/0
et-1/0/1
...
vlan1
vlan10
:param queryset: The base queryset to be ordered
:param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
:param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
"""
ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
return queryset.extra(select={
'_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
}).order_by(*ordering)
#
# Sites
#
@@ -233,6 +200,7 @@ class SiteManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Site(CreatedUpdatedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -256,19 +224,19 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:site', args=[self.slug])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.slug,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.facility,
str(self.asn) if self.asn else '',
self.asn,
self.contact_name,
self.contact_phone,
self.contact_email,
@@ -299,6 +267,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
# Racks
#
@python_2_unicode_compatible
class RackGroup(models.Model):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -316,13 +285,14 @@ class RackGroup(models.Model):
['site', 'slug'],
]
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@python_2_unicode_compatible
class RackRole(models.Model):
"""
Racks can be organized by functional role, similar to Devices.
@@ -334,7 +304,7 @@ class RackRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -347,6 +317,7 @@ class RackManager(NaturalOrderByManager):
return self.natural_order_by('site__name', 'name')
@python_2_unicode_compatible
class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -377,7 +348,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
['site', 'facility_id'],
]
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -398,17 +369,17 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
})
def to_csv(self):
return ','.join([
return csv_format([
self.site.name,
self.group.name if self.group else '',
self.group.name if self.group else None,
self.name,
self.facility_id or '',
self.tenant.name if self.tenant else '',
self.role.name if self.role else '',
self.get_type_display() if self.type else '',
str(self.width),
str(self.u_height),
'True' if self.desc_units else '',
self.facility_id,
self.tenant.name if self.tenant else None,
self.role.name if self.role else None,
self.get_type_display() if self.type else None,
self.width,
self.u_height,
self.desc_units,
])
@property
@@ -476,7 +447,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
units = list(range(1, self.u_height + 1))
# Remove units consumed by installed devices
for d in devices:
@@ -511,6 +482,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
# Device Types
#
@python_2_unicode_compatible
class Manufacturer(models.Model):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -521,13 +493,14 @@ class Manufacturer(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
@python_2_unicode_compatible
class DeviceType(models.Model, CustomFieldModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -550,6 +523,8 @@ class DeviceType(models.Model, CustomFieldModel):
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces")
interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES,
default=IFACE_ORDERING_POSITION)
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="This type of device has console server ports")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
@@ -570,7 +545,7 @@ class DeviceType(models.Model, CustomFieldModel):
['manufacturer', 'slug'],
]
def __unicode__(self):
def __str__(self):
return self.model
def __init__(self, *args, **kwargs):
@@ -640,6 +615,7 @@ class DeviceType(models.Model, CustomFieldModel):
return bool(self.subdevice_role is False)
@python_2_unicode_compatible
class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new Device.
@@ -651,10 +627,11 @@ class ConsolePortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new Device.
@@ -666,10 +643,11 @@ class ConsoleServerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new Device.
@@ -681,10 +659,11 @@ class PowerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new Device.
@@ -696,17 +675,49 @@ class PowerOutletTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
class InterfaceTemplateManager(models.Manager):
class InterfaceManager(models.Manager):
def get_queryset(self):
qs = super(InterfaceTemplateManager, self).get_queryset()
return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
slot, subslot, position, and channel:
{name}{slot}/{subslot}/{position}:{channel}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows:
name = 'GigabitEthernet'
slot = None
subslot = 0
position = 1
channel = None
The chosen sorting method will determine which fields are ordered first in the query.
"""
queryset = self.get_queryset()
sql_col = '{}.name'.format(queryset.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
}[method]
return queryset.extra(select={
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
}).order_by(*ordering)
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
@@ -716,16 +727,17 @@ class InterfaceTemplate(models.Model):
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
objects = InterfaceTemplateManager()
objects = InterfaceManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class DeviceBayTemplate(models.Model):
"""
A template for a DeviceBay to be created for a new parent Device.
@@ -737,7 +749,7 @@ class DeviceBayTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@@ -745,6 +757,7 @@ class DeviceBayTemplate(models.Model):
# Devices
#
@python_2_unicode_compatible
class DeviceRole(models.Model):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -757,13 +770,14 @@ class DeviceRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
@python_2_unicode_compatible
class Platform(models.Model):
"""
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
@@ -777,7 +791,7 @@ class Platform(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -790,6 +804,7 @@ class DeviceManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Device(CreatedUpdatedModel, CustomFieldModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -829,7 +844,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
ordering = ['name']
unique_together = ['rack', 'position', 'face']
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -910,19 +925,19 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
def to_csv(self):
return ','.join([
return csv_format([
self.name or '',
self.device_role.name,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.device_type.manufacturer.name,
self.device_type.model,
self.platform.name if self.platform else '',
self.platform.name if self.platform else None,
self.serial,
self.asset_tag if self.asset_tag else '',
self.asset_tag,
self.rack.site.name,
self.rack.name,
str(self.position) if self.position else '',
self.get_face_display() or '',
self.position,
self.get_face_display(),
])
@property
@@ -969,6 +984,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
return RPC_CLIENTS.get(self.platform.rpc_client)
@python_2_unicode_compatible
class ConsolePort(models.Model):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -983,17 +999,14 @@ class ConsolePort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def to_csv(self):
return ','.join([
self.cs_port.device.identifier if self.cs_port else '',
self.cs_port.name if self.cs_port else '',
return csv_format([
self.cs_port.device.identifier if self.cs_port else None,
self.cs_port.name if self.cs_port else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
@@ -1015,6 +1028,7 @@ class ConsoleServerPortManager(models.Manager):
}).order_by('device', 'name_as_integer')
@python_2_unicode_compatible
class ConsoleServerPort(models.Model):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -1027,13 +1041,11 @@ class ConsoleServerPort(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
@python_2_unicode_compatible
class PowerPort(models.Model):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -1048,17 +1060,14 @@ class PowerPort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def to_csv(self):
def csv_format(self):
return ','.join([
self.power_outlet.device.identifier if self.power_outlet else '',
self.power_outlet.name if self.power_outlet else '',
self.power_outlet.device.identifier if self.power_outlet else None,
self.power_outlet.name if self.power_outlet else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
@@ -1074,6 +1083,7 @@ class PowerOutletManager(models.Manager):
}).order_by('device', 'name_padded')
@python_2_unicode_compatible
class PowerOutlet(models.Model):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -1086,26 +1096,11 @@ class PowerOutlet(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class InterfaceManager(models.Manager):
def get_queryset(self):
qs = super(InterfaceManager, self).get_queryset()
return order_interfaces(qs, 'dcim_interface.name', ('device',))
def virtual(self):
return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)
def physical(self):
return self.get_queryset().exclude(form_factor=IFACE_FF_VIRTUAL)
@python_2_unicode_compatible
class Interface(models.Model):
"""
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
@@ -1125,12 +1120,9 @@ class Interface(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
@@ -1196,7 +1188,7 @@ class InterfaceConnection(models.Model):
# Used for connections export
def to_csv(self):
return ','.join([
return csv_format([
self.interface_a.device.identifier,
self.interface_a.name,
self.interface_b.device.identifier,
@@ -1205,6 +1197,7 @@ class InterfaceConnection(models.Model):
])
@python_2_unicode_compatible
class DeviceBay(models.Model):
"""
An empty space within a Device which can house a child device
@@ -1218,12 +1211,9 @@ class DeviceBay(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.device.name, self.name)
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self):
# Validate that the parent Device can have DeviceBays
@@ -1237,6 +1227,7 @@ class DeviceBay(models.Model):
raise ValidationError("Cannot install a device into itself.")
@python_2_unicode_compatible
class Module(models.Model):
"""
A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
@@ -1255,8 +1246,5 @@ class Module(models.Model):
ordering = ['device__id', 'parent__id', 'name']
unique_together = ['device', 'parent', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return reverse('dcim:device_inventory', args=[self.device.pk])

View File

@@ -311,7 +311,8 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
@@ -327,7 +328,8 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')

View File

@@ -65,7 +65,7 @@ class SiteTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -75,7 +75,7 @@ class SiteTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -84,9 +84,9 @@ class SiteTest(APITestCase):
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
@@ -99,9 +99,9 @@ class SiteTest(APITestCase):
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
@@ -159,7 +159,7 @@ class RackTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -173,7 +173,7 @@ class RackTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -202,7 +202,7 @@ class ManufacturersTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -212,7 +212,7 @@ class ManufacturersTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -232,12 +232,14 @@ class DeviceTypeTest(APITestCase):
'part_number',
'u_height',
'is_full_depth',
'interface_ordering',
'is_console_server',
'is_pdu',
'is_network_device',
'subdevice_role',
'comments',
'custom_fields',
'instance_count',
]
nested_fields = [
@@ -249,7 +251,7 @@ class DeviceTypeTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -260,7 +262,7 @@ class DeviceTypeTest(APITestCase):
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
# content = json.loads(response.content.decode('utf-8'))
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
@@ -283,7 +285,7 @@ class DeviceRolesTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -293,7 +295,7 @@ class DeviceRolesTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -311,7 +313,7 @@ class PlatformsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -321,7 +323,7 @@ class PlatformsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -359,7 +361,7 @@ class DeviceTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
@@ -424,7 +426,7 @@ class DeviceTest(APITestCase):
]
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
device = content[0]
self.assertEqual(
@@ -434,7 +436,7 @@ class DeviceTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -452,7 +454,7 @@ class ConsoleServerPortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -474,7 +476,7 @@ class ConsolePortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -492,7 +494,7 @@ class ConsolePortsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -513,7 +515,7 @@ class PowerPortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -527,7 +529,7 @@ class PowerPortsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -548,7 +550,7 @@ class PowerOutletsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -598,7 +600,7 @@ class InterfaceTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -612,7 +614,7 @@ class InterfaceTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -624,19 +626,19 @@ class InterfaceTest(APITestCase):
)
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
response = self.client.get(endpoint)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -658,7 +660,7 @@ class RelatedConnectionsTest(APITestCase):
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
.format(settings.BASE_PATH))):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),

View File

@@ -71,7 +71,7 @@ class ComponentCreateView(View):
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': self.form(initial=request.GET),
'cancel_url': parent.get_absolute_url(),
'return_url': parent.get_absolute_url(),
})
def post(self, request, pk):
@@ -112,10 +112,22 @@ class ComponentCreateView(View):
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'cancel_url': parent.get_absolute_url(),
'return_url': parent.get_absolute_url(),
})
class ComponentEditView(ObjectEditView):
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, obj):
return obj.device.get_absolute_url()
#
# Sites
#
@@ -125,7 +137,6 @@ class SiteListView(ObjectListView):
filter = filters.SiteFilter
filter_form = forms.SiteFilterForm
table = tables.SiteTable
edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
template_name = 'dcim/site_list.html'
@@ -157,13 +168,13 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
model = Site
form_class = forms.SiteForm
template_name = 'dcim/site_edit.html'
obj_list_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
model = Site
redirect_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -171,15 +182,16 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.SiteImportForm
table = tables.SiteTable
template_name = 'dcim/site_import.html'
obj_list_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_site'
cls = Site
filter = filters.SiteFilter
form = forms.SiteBulkEditForm
template_name = 'dcim/site_bulk_edit.html'
default_redirect_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
#
@@ -191,7 +203,6 @@ class RackGroupListView(ObjectListView):
filter = filters.RackGroupFilter
filter_form = forms.RackGroupFilterForm
table = tables.RackGroupTable
edit_permissions = ['dcim.change_rackgroup', 'dcim.delete_rackgroup']
template_name = 'dcim/rackgroup_list.html'
@@ -199,14 +210,16 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
obj_list_url = 'dcim:rackgroup_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:rackgroup_list')
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
cls = RackGroup
default_redirect_url = 'dcim:rackgroup_list'
filter = filters.RackGroupFilter
default_return_url = 'dcim:rackgroup_list'
#
@@ -216,7 +229,6 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackRoleListView(ObjectListView):
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
template_name = 'dcim/rackrole_list.html'
@@ -224,14 +236,15 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole'
model = RackRole
form_class = forms.RackRoleForm
obj_list_url = 'dcim:rackrole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:rackrole_list')
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
default_redirect_url = 'dcim:rackrole_list'
default_return_url = 'dcim:rackrole_list'
#
@@ -244,7 +257,6 @@ class RackListView(ObjectListView):
filter = filters.RackFilter
filter_form = forms.RackFilterForm
table = tables.RackTable
edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
template_name = 'dcim/rack_list.html'
@@ -272,13 +284,13 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
model = Rack
form_class = forms.RackForm
template_name = 'dcim/rack_edit.html'
obj_list_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack'
model = Rack
redirect_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -286,21 +298,23 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.RackImportForm
table = tables.RackImportTable
template_name = 'dcim/rack_import.html'
obj_list_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rack'
cls = Rack
filter = filters.RackFilter
form = forms.RackBulkEditForm
template_name = 'dcim/rack_bulk_edit.html'
default_redirect_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack'
cls = Rack
default_redirect_url = 'dcim:rack_list'
filter = filters.RackFilter
default_return_url = 'dcim:rack_list'
#
@@ -310,7 +324,6 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ManufacturerListView(ObjectListView):
queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
table = tables.ManufacturerTable
edit_permissions = ['dcim.change_manufacturer', 'dcim.delete_manufacturer']
template_name = 'dcim/manufacturer_list.html'
@@ -318,14 +331,15 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
obj_list_url = 'dcim:manufacturer_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:manufacturer_list')
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer
default_redirect_url = 'dcim:manufacturer_list'
default_return_url = 'dcim:manufacturer_list'
#
@@ -337,7 +351,6 @@ class DeviceTypeListView(ObjectListView):
filter = filters.DeviceTypeFilter
filter_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
edit_permissions = ['dcim.change_devicetype', 'dcim.delete_devicetype']
template_name = 'dcim/devicetype_list.html'
@@ -358,10 +371,14 @@ def devicetype(request, pk):
poweroutlet_table = tables.PowerOutletTemplateTable(
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=True))
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=False))
mgmt_interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
mgmt_only=True))
)
interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
mgmt_only=False))
)
devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
@@ -391,27 +408,29 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
model = DeviceType
form_class = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
obj_list_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype'
model = DeviceType
redirect_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype'
cls = DeviceType
filter = filters.DeviceTypeFilter
form = forms.DeviceTypeBulkEditForm
template_name = 'dcim/devicetype_bulk_edit.html'
default_redirect_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype'
cls = DeviceType
default_redirect_url = 'dcim:devicetype_list'
filter = filters.DeviceTypeFilter
default_return_url = 'dcim:devicetype_list'
#
@@ -525,7 +544,6 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
table = tables.DeviceRoleTable
edit_permissions = ['dcim.change_devicerole', 'dcim.delete_devicerole']
template_name = 'dcim/devicerole_list.html'
@@ -533,14 +551,15 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
obj_list_url = 'dcim:devicerole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:devicerole_list')
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
default_redirect_url = 'dcim:devicerole_list'
default_return_url = 'dcim:devicerole_list'
#
@@ -550,7 +569,6 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PlatformListView(ObjectListView):
queryset = Platform.objects.annotate(device_count=Count('devices'))
table = tables.PlatformTable
edit_permissions = ['dcim.change_platform', 'dcim.delete_platform']
template_name = 'dcim/platform_list.html'
@@ -558,14 +576,15 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform'
model = Platform
form_class = forms.PlatformForm
obj_list_url = 'dcim:platform_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:platform_list')
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform
default_redirect_url = 'dcim:platform_list'
default_return_url = 'dcim:platform_list'
#
@@ -578,7 +597,6 @@ class DeviceListView(ObjectListView):
filter = filters.DeviceFilter
filter_form = forms.DeviceFilterForm
table = tables.DeviceTable
edit_permissions = ['dcim.change_device', 'dcim.delete_device']
template_name = 'dcim/device_list.html'
@@ -597,16 +615,14 @@ def device(request, pk):
power_outlets = natsorted(
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
)
interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related(
'connected_as_a__interface_b__device',
'connected_as_b__interface_a__device',
'circuit_termination__circuit',
)
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related(
'connected_as_a__interface_b__device',
'connected_as_b__interface_a__device',
'circuit_termination__circuit',
)
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=False)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
@@ -659,13 +675,13 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
model = Device
redirect_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -673,7 +689,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.DeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -681,7 +697,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ChildDeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
def save_obj(self, obj):
# Inherent rack from parent device
@@ -696,15 +712,17 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
cls = Device
filter = filters.DeviceFilter
form = forms.DeviceBulkEditForm
template_name = 'dcim/device_bulk_edit.html'
default_redirect_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device'
cls = Device
default_redirect_url = 'dcim:device_list'
filter = filters.DeviceFilter
default_return_url = 'dcim:device_list'
def device_inventory(request, pk):
@@ -722,7 +740,8 @@ def device_inventory(request, pk):
def device_lldp_neighbors(request, pk):
device = get_object_or_404(Device, pk=pk)
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b')
return render(request, 'dcim/device_lldp_neighbors.html', {
'device': device,
@@ -769,7 +788,7 @@ def consoleport_connect(request, pk):
return render(request, 'dcim/consoleport_connect.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
@@ -798,17 +817,17 @@ def consoleport_disconnect(request, pk):
return render(request, 'dcim/consoleport_disconnect.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleport'
model = ConsolePort
form_class = forms.ConsolePortForm
class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleport'
model = ConsolePort
@@ -865,7 +884,7 @@ def consoleserverport_connect(request, pk):
return render(request, 'dcim/consoleserverport_connect.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
@@ -895,17 +914,17 @@ def consoleserverport_disconnect(request, pk):
return render(request, 'dcim/consoleserverport_disconnect.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
form_class = forms.ConsoleServerPortForm
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort
@@ -955,7 +974,7 @@ def powerport_connect(request, pk):
return render(request, 'dcim/powerport_connect.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
@@ -984,17 +1003,17 @@ def powerport_disconnect(request, pk):
return render(request, 'dcim/powerport_disconnect.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_powerport'
model = PowerPort
form_class = forms.PowerPortForm
class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_powerport'
model = PowerPort
@@ -1051,7 +1070,7 @@ def poweroutlet_connect(request, pk):
return render(request, 'dcim/poweroutlet_connect.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
@@ -1080,17 +1099,17 @@ def poweroutlet_disconnect(request, pk):
return render(request, 'dcim/poweroutlet_disconnect.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
form_class = forms.PowerOutletForm
class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet
@@ -1114,13 +1133,13 @@ class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
model_form = forms.InterfaceForm
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_interface'
model = Interface
form_class = forms.InterfaceForm
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
@@ -1152,13 +1171,13 @@ class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
model_form = forms.DeviceBayForm
class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_devicebay'
model = DeviceBay
form_class = forms.DeviceBayForm
class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_devicebay'
model = DeviceBay
@@ -1185,7 +1204,7 @@ def devicebay_populate(request, pk):
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
@@ -1209,7 +1228,7 @@ def devicebay_depopulate(request, pk):
return render(request, 'dcim/devicebay_depopulate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
@@ -1238,7 +1257,7 @@ class DeviceBulkAddComponentView(View):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
@@ -1284,7 +1303,7 @@ class DeviceBulkAddComponentView(View):
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'selected_devices': selected_devices,
'cancel_url': reverse('dcim:device_list'),
'return_url': reverse('dcim:device_list'),
})
@@ -1366,7 +1385,7 @@ def interfaceconnection_add(request, pk):
return render(request, 'dcim/interfaceconnection_edit.html', {
'device': device,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
@@ -1398,15 +1417,15 @@ def interfaceconnection_delete(request, pk):
# Determine where to direct user upon cancellation
if device_id:
cancel_url = reverse('dcim:device', kwargs={'pk': device_id})
return_url = reverse('dcim:device', kwargs={'pk': device_id})
else:
cancel_url = reverse('dcim:device_list')
return_url = reverse('dcim:device_list')
return render(request, 'dcim/interfaceconnection_delete.html', {
'interfaceconnection': interfaceconnection,
'device_id': device_id,
'form': form,
'cancel_url': cancel_url,
'return_url': return_url,
})
@@ -1485,7 +1504,7 @@ def ipaddress_assign(request, pk):
return render(request, 'dcim/ipaddress_assign.html', {
'device': device,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
@@ -1493,18 +1512,17 @@ def ipaddress_assign(request, pk):
# Modules
#
class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_module'
model = Module
form_class = forms.ModuleForm
def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs:
device = get_object_or_404(Device, pk=kwargs['device'])
obj.device = device
obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj
class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_module'
model = Module

View File

@@ -50,7 +50,7 @@ class FlatJSONRenderer(renderers.BaseRenderer):
def render(self, data, media_type=None, renderer_context=None):
def flatten(entry):
for key, val in entry.iteritems():
for key, val in entry.items():
if isinstance(val, dict):
for child_key, child_val in flatten(val):
yield "{}_{}".format(key, child_key), child_val

View File

@@ -34,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(0, 'False'),
)
if cf.default.lower() in ['true', 'yes', '1']:
initial = True
initial = 1
elif cf.default.lower() in ['false', 'no', '0']:
initial = False
initial = 0
else:
initial = None
field = forms.NullBooleanField(required=cf.required, initial=initial,

View File

@@ -8,6 +8,7 @@ from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
@@ -93,6 +94,7 @@ class CustomFieldModel(object):
return OrderedDict([(field, None) for field in fields])
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -114,7 +116,7 @@ class CustomField(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
@@ -153,6 +155,7 @@ class CustomField(models.Model):
return serialized_value
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
@@ -164,7 +167,7 @@ class CustomFieldValue(models.Model):
ordering = ['obj_type', 'obj_id']
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.obj, self.field)
@property
@@ -183,6 +186,7 @@ class CustomFieldValue(models.Model):
super(CustomFieldValue, self).save(*args, **kwargs)
@python_2_unicode_compatible
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
@@ -193,7 +197,7 @@ class CustomFieldChoice(models.Model):
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __unicode__(self):
def __str__(self):
return self.value
def clean(self):
@@ -207,6 +211,7 @@ class CustomFieldChoice(models.Model):
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
@python_2_unicode_compatible
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
@@ -217,7 +222,7 @@ class Graph(models.Model):
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def embed_url(self, obj):
@@ -231,6 +236,7 @@ class Graph(models.Model):
return template.render(Context({'obj': obj}))
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=100)
@@ -245,7 +251,7 @@ class ExportTemplate(models.Model):
['content_type', 'name']
]
def __unicode__(self):
def __str__(self):
return u'{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename):
@@ -264,6 +270,7 @@ class ExportTemplate(models.Model):
return response
@python_2_unicode_compatible
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
@@ -278,7 +285,7 @@ class TopologyMap(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -328,6 +335,7 @@ class UserActionManager(models.Manager):
self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
@python_2_unicode_compatible
class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
@@ -344,7 +352,7 @@ class UserAction(models.Model):
class Meta:
ordering = ['-time']
def __unicode__(self):
def __str__(self):
if self.message:
return u'{} {}'.format(self.user, self.message)
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)

View File

@@ -1,8 +1,8 @@
#!/usr/bin/python
#!/usr/bin/env python
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import os
import random
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
random.seed = (os.urandom(2048))
print ''.join(random.choice(charset) for c in range(50))
print(''.join(random.choice(charset) for c in range(50)))

View File

@@ -126,7 +126,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
vlan_id = NullableModelMultipleChoiceFilter(
name='vlan',
queryset=VLAN.objects.all(),
label='VLAN (ID)',

View File

@@ -63,6 +63,7 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
q = forms.CharField(required=False, label='Search')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
@@ -128,6 +129,7 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
q = forms.CharField(required=False, label='Search')
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
@@ -215,6 +217,8 @@ class PrefixFromCSVForm(forms.ModelForm):
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
@@ -254,8 +258,9 @@ def prefix_status_choices():
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
@@ -334,7 +339,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
@@ -344,9 +349,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -442,7 +449,8 @@ def ipaddress_status_choices():
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
@@ -556,6 +564,7 @@ def vlan_status_choices():
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-23 19:10
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0013_prefix_add_is_pool'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (3, b'Deprecated'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
),
]

View File

@@ -7,12 +7,14 @@ from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .fields import IPNetworkField, IPAddressField
@@ -35,10 +37,12 @@ PREFIX_STATUS_CHOICES = (
IPADDRESS_STATUS_ACTIVE = 1
IPADDRESS_STATUS_RESERVED = 2
IPADDRESS_STATUS_DEPRECATED = 3
IPADDRESS_STATUS_DHCP = 5
IPADDRESS_STATUS_CHOICES = (
(IPADDRESS_STATUS_ACTIVE, 'Active'),
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
(IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
(IPADDRESS_STATUS_DHCP, 'DHCP')
)
@@ -69,6 +73,7 @@ IP_PROTOCOL_CHOICES = (
)
@python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -88,22 +93,23 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('ipam:vrf', args=[self.pk])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.rd,
self.tenant.name if self.tenant else '',
'True' if self.enforce_unique else '',
self.tenant.name if self.tenant else None,
self.enforce_unique,
self.description,
])
@python_2_unicode_compatible
class RIR(models.Model):
"""
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -119,13 +125,14 @@ class RIR(models.Model):
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
@python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -141,7 +148,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
@@ -183,10 +190,10 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
super(Aggregate, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
return csv_format([
self.prefix,
self.rir.name,
self.date_added.isoformat() if self.date_added else '',
self.date_added.isoformat() if self.date_added else None,
self.description,
])
@@ -203,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
return int(children_size / self.prefix.size * 100)
@python_2_unicode_compatible
class Role(models.Model):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -215,7 +223,7 @@ class Role(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -262,6 +270,7 @@ class PrefixQuerySet(NullsFirstQuerySet):
return filter(lambda p: p.depth <= limit, queryset)
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -291,16 +300,20 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
def clean(self):
# Disallow host masks
if self.prefix:
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
@@ -310,6 +323,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes:
raise ValidationError({
'prefix': "Duplicate prefix found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_prefixes.first(),
)
})
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
@@ -319,16 +343,16 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
super(Prefix, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.site.name if self.site else '',
self.vlan.group.name if self.vlan and self.vlan.group else '',
str(self.vlan.vid) if self.vlan else '',
return csv_format([
self.prefix,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.site.name if self.site else None,
self.vlan.group.name if self.vlan and self.vlan.group else None,
self.vlan.vid if self.vlan else None,
self.get_status_display(),
self.role.name if self.role else '',
'True' if self.is_pool else '',
self.role.name if self.role else None,
self.is_pool,
self.description,
])
@@ -361,6 +385,7 @@ class IPAddressManager(models.Manager):
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
@python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -393,29 +418,29 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
def __str__(self):
return str(self.address)
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def get_duplicates(self):
return IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip)).exclude(pk=self.pk)
def clean(self):
# Enforce unique IP space if applicable
if self.vrf and self.vrf.enforce_unique:
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first())
})
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
})
if self.address:
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_ips = self.get_duplicates()
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_ips.first(),
)
})
def save(self, *args, **kwargs):
if self.address:
@@ -432,14 +457,14 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
return ','.join([
str(self.address),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
return csv_format([
self.address,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if is_primary else '',
self.device.identifier if self.device else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
])
@@ -453,6 +478,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
@@ -470,13 +496,14 @@ class VLANGroup(models.Model):
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -508,7 +535,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -523,14 +550,14 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
})
def to_csv(self):
return ','.join([
return csv_format([
self.site.name,
self.group.name if self.group else '',
str(self.vid),
self.group.name if self.group else None,
self.vid,
self.name,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.role.name if self.role else '',
self.role.name if self.role else None,
self.description,
])
@@ -542,6 +569,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
@@ -560,8 +588,5 @@ class Service(CreatedUpdatedModel):
ordering = ['device', 'protocol', 'port']
unique_together = ['device', 'protocol', 'port']
def __unicode__(self):
def __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
def get_parent_url(self):
return self.device.get_absolute_url()

View File

@@ -136,7 +136,7 @@ class VRFTable(BaseTable):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VRF
@@ -182,7 +182,7 @@ class AggregateTable(BaseTable):
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Aggregate
@@ -219,7 +219,7 @@ class PrefixTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Prefix
@@ -234,11 +234,12 @@ class PrefixBriefTable(BaseTable):
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta):
model = Prefix
fields = ('prefix', 'vrf', 'status', 'site', 'role')
fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
orderable = False
@@ -255,7 +256,7 @@ class IPAddressTable(BaseTable):
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = IPAddress
@@ -310,7 +311,8 @@ class VLANTable(BaseTable):
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role')
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

View File

View File

@@ -0,0 +1,60 @@
import netaddr
from django.test import TestCase, override_settings
from ipam.models import IPAddress, Prefix, VRF
from django.core.exceptions import ValidationError
class TestPrefix(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean())
def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
class TestIPAddress(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)
def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean())
def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)

View File

@@ -95,15 +95,16 @@ class VRFListView(ObjectListView):
filter = filters.VRFFilter
filter_form = forms.VRFFilterForm
table = tables.VRFTable
edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
template_name = 'ipam/vrf_list.html'
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf)
prefix_table = tables.PrefixBriefTable(prefixes)
prefix_table = tables.PrefixBriefTable(
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
@@ -116,13 +117,13 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
obj_list_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
model = VRF
redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -130,21 +131,23 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VRFImportForm
table = tables.VRFTable
template_name = 'ipam/vrf_import.html'
obj_list_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
filter = filters.VRFFilter
form = forms.VRFBulkEditForm
template_name = 'ipam/vrf_bulk_edit.html'
default_redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
default_redirect_url = 'ipam:vrf_list'
filter = filters.VRFFilter
default_return_url = 'ipam:vrf_list'
#
@@ -156,7 +159,6 @@ class RIRListView(ObjectListView):
filter = filters.RIRFilter
filter_form = forms.RIRFilterForm
table = tables.RIRTable
edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
template_name = 'ipam/rir_list.html'
def alter_queryset(self, request):
@@ -240,14 +242,16 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
model = RIR
form_class = forms.RIRForm
obj_list_url = 'ipam:rir_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:rir_list')
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
default_redirect_url = 'ipam:rir_list'
filter = filters.RIRFilter
default_return_url = 'ipam:rir_list'
#
@@ -261,7 +265,6 @@ class AggregateListView(ObjectListView):
filter = filters.AggregateFilter
filter_form = forms.AggregateFilterForm
table = tables.AggregateTable
edit_permissions = ['ipam.change_aggregate', 'ipam.delete_aggregate']
template_name = 'ipam/aggregate_list.html'
def extra_context(self):
@@ -305,13 +308,13 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
obj_list_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
model = Aggregate
redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -319,21 +322,23 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.AggregateImportForm
table = tables.AggregateTable
template_name = 'ipam/aggregate_import.html'
obj_list_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
filter = filters.AggregateFilter
form = forms.AggregateBulkEditForm
template_name = 'ipam/aggregate_bulk_edit.html'
default_redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
default_redirect_url = 'ipam:aggregate_list'
filter = filters.AggregateFilter
default_return_url = 'ipam:aggregate_list'
#
@@ -343,7 +348,6 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RoleListView(ObjectListView):
queryset = Role.objects.all()
table = tables.RoleTable
edit_permissions = ['ipam.change_role', 'ipam.delete_role']
template_name = 'ipam/role_list.html'
@@ -351,14 +355,15 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
model = Role
form_class = forms.RoleForm
obj_list_url = 'ipam:role_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:role_list')
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
default_redirect_url = 'ipam:role_list'
default_return_url = 'ipam:role_list'
#
@@ -370,7 +375,6 @@ class PrefixListView(ObjectListView):
filter = filters.PrefixFilter
filter_form = forms.PrefixFilterForm
table = tables.PrefixTable
edit_permissions = ['ipam.change_prefix', 'ipam.delete_prefix']
template_name = 'ipam/prefix_list.html'
def alter_queryset(self, request):
@@ -397,11 +401,13 @@ def prefix(request, pk):
.filter(prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role')
duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes)
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',)
# Child prefixes table
if prefix.vrf:
@@ -426,6 +432,7 @@ def prefix(request, pk):
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
'return_url': prefix.get_absolute_url(),
})
@@ -435,13 +442,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
obj_list_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
redirect_url = 'ipam:prefix_list'
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -449,21 +457,23 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.PrefixImportForm
table = tables.PrefixTable
template_name = 'ipam/prefix_import.html'
obj_list_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
filter = filters.PrefixFilter
form = forms.PrefixBulkEditForm
template_name = 'ipam/prefix_bulk_edit.html'
default_redirect_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
default_redirect_url = 'ipam:prefix_list'
filter = filters.PrefixFilter
default_return_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
@@ -495,7 +505,6 @@ class IPAddressListView(ObjectListView):
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
edit_permissions = ['ipam.change_ipaddress', 'ipam.delete_ipaddress']
template_name = 'ipam/ipaddress_list.html'
@@ -504,18 +513,20 @@ def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
.select_related('site', 'role')
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
duplicate_ips_table = tables.IPAddressBriefTable(duplicate_ips)
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
# Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = tables.IPAddressBriefTable(related_ips)
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
@@ -555,7 +566,7 @@ def ipaddress_assign(request, pk):
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@@ -588,7 +599,7 @@ def ipaddress_remove(request, pk):
return render(request, 'ipam/ipaddress_unassign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@@ -598,13 +609,13 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.IPAddressForm
fields_initial = ['address', 'vrf']
template_name = 'ipam/ipaddress_edit.html'
obj_list_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
@@ -612,7 +623,7 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
form = forms.IPAddressBulkAddForm
model = IPAddress
template_name = 'ipam/ipaddress_bulk_add.html'
redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -620,20 +631,18 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.IPAddressImportForm
table = tables.IPAddressTable
template_name = 'ipam/ipaddress_import.html'
obj_list_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
obj.save()
# Update primary IP for device if needed
# Update primary IP for device if needed. The Device must be updated directly in the database; otherwise we risk
# overwriting a previous IP assignment from the same import (see #861).
try:
if obj.family == 4 and obj.primary_ip4_for:
device = obj.primary_ip4_for
device.primary_ip4 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip4_for.pk).update(primary_ip4=obj)
elif obj.family == 6 and obj.primary_ip6_for:
device = obj.primary_ip6_for
device.primary_ip6 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip6_for.pk).update(primary_ip6=obj)
except Device.DoesNotExist:
pass
@@ -641,15 +650,17 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
filter = filters.IPAddressFilter
form = forms.IPAddressBulkEditForm
template_name = 'ipam/ipaddress_bulk_edit.html'
default_redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
default_redirect_url = 'ipam:ipaddress_list'
filter = filters.IPAddressFilter
default_return_url = 'ipam:ipaddress_list'
#
@@ -661,7 +672,6 @@ class VLANGroupListView(ObjectListView):
filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
template_name = 'ipam/vlangroup_list.html'
@@ -669,14 +679,16 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
obj_list_url = 'ipam:vlangroup_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:vlangroup_list')
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
default_redirect_url = 'ipam:vlangroup_list'
filter = filters.VLANGroupFilter
default_return_url = 'ipam:vlangroup_list'
#
@@ -688,15 +700,15 @@ class VLANListView(ObjectListView):
filter = filters.VLANFilter
filter_form = forms.VLANFilterForm
table = tables.VLANTable
edit_permissions = ['ipam.change_vlan', 'ipam.delete_vlan']
template_name = 'ipam/vlan_list.html'
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan)
prefix_table = tables.PrefixBriefTable(prefixes)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
@@ -709,13 +721,13 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
obj_list_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
model = VLAN
redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -723,21 +735,23 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VLANImportForm
table = tables.VLANTable
template_name = 'ipam/vlan_import.html'
obj_list_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
filter = filters.VLANFilter
form = forms.VLANBulkEditForm
template_name = 'ipam/vlan_bulk_edit.html'
default_redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
default_redirect_url = 'ipam:vlan_list'
filter = filters.VLANFilter
default_return_url = 'ipam:vlan_list'
#
@@ -755,6 +769,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'

View File

@@ -6,13 +6,13 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured
try:
import configuration
from netbox import configuration
except ImportError:
raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
"the documentation.")
VERSION = '1.8.0'
VERSION = '1.8.4'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -50,7 +50,7 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined
LDAP_IGNORE_CERT_ERRORS = False
try:
from ldap_config import *
from netbox.ldap_config import *
LDAP_CONFIGURED = True
except ImportError:
LDAP_CONFIGURED = False
@@ -189,11 +189,6 @@ REST_FRAMEWORK = {
if LOGIN_REQUIRED:
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
# Swagger settings (API docs)
SWAGGER_SETTINGS = {
'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
}
# Django debug toolbar
INTERNAL_IPS = (
'127.0.0.1',

View File

@@ -2,7 +2,7 @@ from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from views import home, handle_500, trigger_500
from netbox.views import home, handle_500, trigger_500
from users.views import login, logout

View File

@@ -9,6 +9,14 @@ $(document).ready(function() {
$('#select_all').prop('checked', false);
}
});
// Enable hidden buttons when "select all" is checked
$('#select_all').click(function (event) {
if ($(this).is(':checked')) {
$('#select_all_box').find('button').prop('disabled', '');
} else {
$('#select_all_box').find('button').prop('disabled', 'disabled');
}
});
// Uncheck the "toggle all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {

View File

@@ -48,7 +48,7 @@ $(document).ready(function() {
$('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show');
$.ajax({
url: '/api/secrets/generate-keys/',
url: netbox_api_path + 'secrets/generate-keys/',
type: 'GET',
dataType: 'json',
success: function (response, status) {
@@ -75,7 +75,7 @@ $(document).ready(function() {
function unlock_secret(secret_id, private_key) {
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: '/api/secrets/secrets/' + secret_id + '/',
url: netbox_api_path + 'secrets/secrets/' + secret_id + '/',
type: 'POST',
data: {
private_key: private_key

View File

@@ -100,6 +100,7 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
class SecretFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(required=False, label='Search')
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')

View File

@@ -8,7 +8,7 @@ from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import force_bytes
from django.utils.encoding import force_bytes, python_2_unicode_compatible
from dcim.models import Device
from utilities.models import CreatedUpdatedModel
@@ -51,6 +51,7 @@ class UserKeyQuerySet(models.QuerySet):
raise Exception("Bulk deletion has been disabled.")
@python_2_unicode_compatible
class UserKey(CreatedUpdatedModel):
"""
A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
@@ -76,7 +77,7 @@ class UserKey(CreatedUpdatedModel):
self.__initial_public_key = self.public_key
self.__initial_master_key_cipher = self.master_key_cipher
def __unicode__(self):
def __str__(self):
return self.user.username
def clean(self, *args, **kwargs):
@@ -170,6 +171,7 @@ class UserKey(CreatedUpdatedModel):
self.save()
@python_2_unicode_compatible
class SecretRole(models.Model):
"""
A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
@@ -186,7 +188,7 @@ class SecretRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -201,6 +203,7 @@ class SecretRole(models.Model):
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
@python_2_unicode_compatible
class Secret(CreatedUpdatedModel):
"""
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
@@ -227,7 +230,7 @@ class Secret(CreatedUpdatedModel):
self.plaintext = kwargs.pop('plaintext', None)
super(Secret, self).__init__(*args, **kwargs)
def __unicode__(self):
def __str__(self):
if self.role and self.device:
return u'{} for {}'.format(self.role, self.device)
return u'Secret'

View File

@@ -1 +0,0 @@
from test_models import *

View File

@@ -22,7 +22,6 @@ from .models import SecretRole, Secret, UserKey
class SecretRoleListView(ObjectListView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
edit_permissions = ['secrets.change_secretrole', 'secrets.delete_secretrole']
template_name = 'secrets/secretrole_list.html'
@@ -30,14 +29,15 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
obj_list_url = 'secrets:secretrole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('secrets:secretrole_list')
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
default_redirect_url = 'secrets:secretrole_list'
default_return_url = 'secrets:secretrole_list'
#
@@ -50,7 +50,6 @@ class SecretListView(ObjectListView):
filter = filters.SecretFilter
filter_form = forms.SecretFilterForm
table = tables.SecretTable
edit_permissions = ['secrets.change_secret', 'secrets.delete_secret']
template_name = 'secrets/secret_list.html'
@@ -102,7 +101,7 @@ def secret_add(request, pk):
return render(request, 'secrets/secret_edit.html', {
'secret': secret,
'form': form,
'cancel_url': device.get_absolute_url(),
'return_url': device.get_absolute_url(),
})
@@ -144,14 +143,14 @@ def secret_edit(request, pk):
return render(request, 'secrets/secret_edit.html', {
'secret': secret,
'form': form,
'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
})
class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'secrets.delete_secret'
model = Secret
redirect_url = 'secrets:secret_list'
default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret')
@@ -194,19 +193,21 @@ def secret_import(request):
return render(request, 'secrets/secret_import.html', {
'form': form,
'cancel_url': reverse('secrets:secret_list'),
'return_url': reverse('secrets:secret_list'),
})
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'secrets.change_secret'
cls = Secret
filter = filters.SecretFilter
form = forms.SecretBulkEditForm
template_name = 'secrets/secret_bulk_edit.html'
default_redirect_url = 'secrets:secret_list'
default_return_url = 'secrets:secret_list'
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret'
cls = Secret
default_redirect_url = 'secrets:secret_list'
filter = filters.SecretFilter
default_return_url = 'secrets:secret_list'

View File

@@ -296,6 +296,9 @@
</div>
</div>
</footer>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
</script>
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'jquery-ui-1.11.4/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.3.6-dist/js/bootstrap.min.js' %}"></script>

View File

@@ -92,6 +92,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if circuit.description %}
{{ circuit.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% with circuit.get_custom_fields as custom_fields %}

View File

@@ -11,6 +11,7 @@
{% render_field form.tenant %}
{% render_field form.install_date %}
{% render_field form.commit_rate %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
@@ -58,10 +58,15 @@
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000</pre>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
</div>
</div>
{% endblock %}

View File

@@ -24,7 +24,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -83,7 +83,7 @@
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>

View File

@@ -15,7 +15,7 @@
</a>
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger">
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -23,7 +23,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -19,7 +19,7 @@
{% render_table table 'table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -40,7 +40,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -40,7 +40,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
{% block title %}{{ device }}{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='info' %}
{% include 'dcim/inc/device_header.html' with active_tab='info' %}
<div class="row">
<div class="col-md-5 col-lg-6">
<div class="panel panel-default">
@@ -183,7 +183,7 @@
{% if ip_addresses %}
<table class="table table-hover panel-body">
{% for ip in ip_addresses %}
{% include 'dcim/inc/_ipaddress.html' %}
{% include 'dcim/inc/ipaddress.html' %}
{% endfor %}
</table>
{% elif interfaces or mgmt_interfaces %}
@@ -212,7 +212,7 @@
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'dcim/inc/_service.html' %}
{% include 'dcim/inc/service.html' %}
{% endfor %}
</table>
{% else %}
@@ -234,7 +234,7 @@
</div>
<table class="table table-hover panel-body">
{% for iface in mgmt_interfaces %}
{% include 'dcim/inc/_interface.html' with icon='wrench' %}
{% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
@@ -246,7 +246,7 @@
</tr>
{% endfor %}
{% for cp in console_ports %}
{% include 'dcim/inc/_consoleport.html' %}
{% include 'dcim/inc/consoleport.html' %}
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
@@ -258,7 +258,7 @@
</tr>
{% endfor %}
{% for pp in power_ports %}
{% include 'dcim/inc/_powerport.html' %}
{% include 'dcim/inc/powerport.html' %}
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
@@ -349,7 +349,7 @@
</div>
<table class="table table-hover panel-body">
{% for devicebay in device_bays %}
{% include 'dcim/inc/_devicebay.html' with selectable=True %}
{% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No device bays defined</td>
@@ -401,7 +401,7 @@
</div>
<table class="table table-hover panel-body">
{% for iface in interfaces %}
{% include 'dcim/inc/_interface.html' with selectable=True %}
{% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No interfaces defined</td>
@@ -458,7 +458,7 @@
</div>
<table class="table table-hover panel-body">
{% for csp in cs_ports %}
{% include 'dcim/inc/_consoleserverport.html' with selectable=True %}
{% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No console server ports defined</td>
@@ -510,7 +510,7 @@
</div>
<table class="table table-hover panel-body">
{% for po in power_outlets %}
{% include 'dcim/inc/_poweroutlet.html' with selectable=True %}
{% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No power outlets defined</td>
@@ -548,9 +548,10 @@
{% block javascript %}
<script type="text/javascript">
function toggleConnection(elem, api_url) {
var url = netbox_api_path + api_url + elem.attr('data') + "/";
if (elem.hasClass('connected')) {
$.ajax({
url: api_url + elem.attr('data') + "/",
url: url,
method: 'PATCH',
dataType: 'json',
beforeSend: function(xhr, settings) {
@@ -569,7 +570,7 @@ function toggleConnection(elem, api_url) {
});
} else {
$.ajax({
url: api_url + elem.attr('data') + "/",
url: url,
method: 'PATCH',
dataType: 'json',
beforeSend: function(xhr, settings) {
@@ -590,13 +591,13 @@ function toggleConnection(elem, api_url) {
return false;
}
$(".consoleport-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
return toggleConnection($(this), "dcim/console-ports/");
});
$(".powerport-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
return toggleConnection($(this), "dcim/power-ports/");
});
$(".interface-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
return toggleConnection($(this), "dcim/interface-connections/");
});
</script>
<script src="{% static 'js/graphs.js' %}"></script>

View File

@@ -5,8 +5,8 @@
<h1>Add {{ component_name|title }}</h1>
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% if request.POST.return_url %}
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
{% endif %}
{% for field in form.hidden_fields %}
{{ field }}
@@ -51,7 +51,7 @@
<div class="form-group text-right">
<div class="col-md-12">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -34,7 +34,7 @@
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_import_header.html' %}
{% include 'dcim/inc/device_import_header.html' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_import_header.html' with active_tab='child_import' %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -3,7 +3,7 @@
{% block title %}{{ device }} - Inventory{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='inventory' %}
{% include 'dcim/inc/device_header.html' with active_tab='inventory' %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
@@ -67,7 +67,7 @@
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -80,10 +80,10 @@
<td>{{ m2.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m2.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m2.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -96,10 +96,10 @@
<td>{{ m3.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m3.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m3.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -112,10 +112,10 @@
<td>{{ m4.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m4.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m4.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>

View File

@@ -24,7 +24,31 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var model_list = $('#id_device_type_id');
$('#id_manufacturer_id').change(function() {
model_list.empty();
var selected_manufacturers = $(this).val();
if (selected_manufacturers) {
var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id=');
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
$.each(response, function (index, device_type) {
var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")");
model_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}{{ device }} - LLDP Neighbors{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='lldp-neighbors' %}
{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>LLDP Neighbors</strong>

View File

@@ -37,7 +37,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Save</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -72,6 +72,10 @@
{% endif %}
</td>
</tr>
<tr>
<td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td>
</tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@@ -33,7 +33,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Save</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -11,6 +11,12 @@
{% render_field form.part_number %}
{% render_field form.u_height %}
{% render_field form.is_full_depth %}
{% render_field form.interface_ordering %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Function</strong></div>
<div class="panel-body">
{% render_field form.is_console_server %}
{% render_field form.is_pdu %}
{% render_field form.is_network_device %}

View File

@@ -19,7 +19,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleport %}
{% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
</td>

View File

@@ -1,5 +1,5 @@
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleserverport %}
{% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ csp.pk }}" />
</td>

View File

@@ -7,12 +7,12 @@
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}" class="formaction">Device Bays</a></li>{% endif %}
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
</ul>
</div>
{% endif %}

View File

@@ -1,5 +1,5 @@
<tr>
{% if selectable and perms.dcim.delete_devicebay %}
{% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
</td>

View File

@@ -1,5 +1,5 @@
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_interface %}
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td>

View File

@@ -13,7 +13,7 @@
</td>
<td class="text-right">
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}

View File

@@ -1,5 +1,5 @@
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_poweroutlet %}
{% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ po.pk }}" />
</td>

View File

@@ -1,5 +1,5 @@
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_powerport %}
{% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
</td>

View File

@@ -18,7 +18,7 @@
</a>
{% endif %}
{% if perms.ipam.delete_service %}
<a href="{% url 'ipam:service_delete' pk=service.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
</a>
{% endif %}

View File

@@ -19,7 +19,7 @@
{% render_table table 'table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -86,7 +86,7 @@
<div class="form-group">
<button type="submit" name="_create" class="btn btn-primary">Connect</button>
<button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>

View File

@@ -53,7 +53,7 @@
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -19,7 +19,7 @@
{% render_table table 'table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -40,7 +40,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -40,7 +40,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -195,13 +195,13 @@
<div class="rack_header">
<h4>Front</h4>
</div>
{% include 'dcim/inc/_rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %}
</div>
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Rear</h4>
</div>
{% include 'dcim/inc/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -24,7 +24,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -18,7 +18,7 @@
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -23,7 +23,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,32 +0,0 @@
{% load form_helpers %}
{% if filter_form %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="fa fa-filter" aria-hidden="true"></span>
<strong>Filter</strong>
</div>
<div class="panel-body">
<form action="." method="get" class="form">
{% for field in filter_form %}
<div class="form-group">
{% if field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
</div>
{% endfor %}
<div class="text-right">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span> Apply
</button>
<a href="." class="btn btn-default">
<span class="fa fa-remove" aria-hidden="true"></span> Clear
</a>
</div>
</form>
</div>
</div>
{% endif %}

View File

@@ -1,18 +1,39 @@
{% load form_helpers %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="fa fa-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">
<form action="." method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
<span class="input-group-btn">
<form action="." method="get" class="form">
{% for field in filter_form %}
<div class="form-group">
{% if field.name == "q" %}
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
{% elif field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
</div>
{% endfor %}
<div class="text-right">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
<span class="fa fa-search" aria-hidden="true"></span> Apply
</button>
</span>
</div>
<a href="." class="btn btn-default">
<span class="fa fa-remove" aria-hidden="true"></span> Clear
</a>
</div>
</form>
</div>
</div>

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -27,7 +27,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -59,7 +59,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_assign" class="btn btn-primary">Assign</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% block title %}IP Addresses{% endblock %}
@@ -25,7 +24,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More