Initial push to public repo

This commit is contained in:
Jeremy Stretch 2016-03-01 11:23:03 -05:00
commit 27b289ee3b
281 changed files with 26061 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*.pyc
configuration.py
.idea
*.sh
fabfile.py

177
LICENSE.txt Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

51
README.md Normal file
View File

@ -0,0 +1,51 @@
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
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`.
# Components
NetBox understands all of the physical and logical building blocks that comprise network infrastructure, and the manners in which they are all related.
## DCIM
DCIM comprises all the physical installations and connections which comprise a network. NetBox tracks where devices are installed, as well as their individual power, console, and network connections.
**Site:** A physical location (typically a building) where network devices are installed. Devices in different sites cannot be directly connected to one another.
**Rack:** An equipment rack into which devices are installed. Each rack belongs to a site.
**Device:** Any type of rack-mounted device. For example, routers, switches, servers, console servers, PDUs, etc. 0U (non-rack-mounted) devices are supported.
## IPAM
IPAM deals with the IP addressing and VLANs in use on a network. NetBox makes a distinction between IP prefixes (networks) and individual IP addresses.
Because NetBox is a combined DCIM/IPAM system, IP addresses can be assigned to device interfaces in the application just as they are in the real world.
**Aggregate:** A top-level aggregate of IP address space; for example, 10.0.0.0/8 or 2001:db8::/32. Each aggregate belongs to a regional Internet registry (RIR) like ARIN or RIPE, or to an authoritative standard such as RFC 1918.
**VRF:** A virtual routing table. VRF support is currently still under development.
**Prefix:** An IPv4 or IPv6 network. A prefix can be assigned to a VRF; if not, it is considered to belong to the global table. Prefixes are grouped by aggregates automatically and can optionally be assigned to sites.
**IP Address:** An individual IPv4 or IPv6 address (with CIDR mask). IP address can be assigned to device interfaces.
**VLAN:** VLANs are assigned to sites, and can optionally have one or more IP prefixes assigned to them. VLAN IDs are unique only within the scope of a site.
## Circuits
Long-distance data connections are typically referred to as _circuits_. NetBox provides a method for managing circuits and their providers. Individual circuits can be terminated to device interfaces.
**Provider:** An entity to which a network connects to. This can be a transit provider, peer, or some other organization.
**Circuit:** A data circuit which connects to a provider. The local end of a circuit can be assigned to a device interface.
## Secrets
NetBox provides encrypted storage of sensitive data it calls _secrets_. Each user may be issued an encryption key with which stored secrets can be retrieved.
Note that NetBox does not merely hash secrets, a function which is only useful for validation. It employs fully reversible AES-256 encryption so that secret data can be retrieved and consumed by other services.
**Secrets** Any piece of confidential data which must be retrievable. For example: passwords, SNMP communities, RADIUS shared secrets, etc.
**User Key:** An individual user's encrypted copy of the master key, which can be used to retrieve secret data.

View File

@ -0,0 +1,30 @@
Circuits are communication links which connect two endpoints, typically over long distances. For example, a circuit might connect an enterprise to its Internet service provider. NetBox can track circuits and their providers.
# Providers
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.
# 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
## Circuit Type
Circuits can be classified by type. For example:
* Internet transit
* Out-of-band connectivity
* Peering
* Private backhaul
Each circuit must be assigned exactly one circuit type.

84
docs/data-model/dcim.md Normal file
View File

@ -0,0 +1,84 @@
The data center infrastructure management (DCIM) component of NetBox assists in the management of physical assets within a network: equipment racks, the gear in them, and the cabling that connects it all.
# Sites
A site is a geographic location at which network equipment is housed. How you choose to define sites will depend on the nature of your organization, but typically a site will be a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
# Racks
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
## Rack Groups
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room.
Each group is assigned to a parent sire for easy navigation. Hierarchical recursion of rack groups is not currently supported.
# Devices
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and whether they are full depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow.
Each device has a physical device type (make and model), which is discussed below.
## Device Roles
NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, device can only belong to one device role.
## Platform
A device's platform is used to denote the type of software running on it. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
The assignment of platforms to devices is an entirely optional feature, and may be disregarded if not desired.
## Modules
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand.
## Device Components
There are five types of device components which comprise all of the interconnection logic with NetBox:
* Console ports
* Console server ports
* Power ports
* Power outlets
* Interfaces
Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
Each type of connection can be defined as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed.
In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
# Device Types
A device type represents a particular manufacturer and model of equipment. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data).
## Manufacturers
Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. Manufacturers are used to group different models of device.
## Device Component Templates
Each device type is assigned a number of component templates which describe the console, power, and data ports a device has. These are:
* Console port templates
* Console server port templates
* Power port templates
* Power outlet templates
* Interface templates
Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
* One template for a console port ("Console")
* Two templates for power ports ("PSU0" and "PSU1")
* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47")
* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3")
Once you've done this, every new device that you create as an instance of this type will automatically be assigned each of the components listed above.
Note that assignment of components from templates occurs only at the time of device creation: If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually.

80
docs/data-model/ipam.md Normal file
View File

@ -0,0 +1,80 @@
IP address management (IPAM) entails the allocation of IP networks and addresses. Within NetBox, at least, IPAM also includes the management of VLAN assignments.
# VRF
A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network.
Each VRF is assigned a name and a unique route distinguisher (RD). VRFs are an optional feature of NetBox: Any IP prefix or address not assigned to a VRF is said to belong to the "global" table.
# Aggregate
IPv4 address space is organized as a hierarchy, with more-specific (smaller) prefix arranged as child nodes under less-specific (larger) prefixes. For example:
* 10.0.0.0/8
* 10.1.0.0/16
* 10.1.2.0/24
The root of the IPv4 hierarchy is 0.0.0.0/0, which encompasses all possible IPv4 addresses (and similarly, ::/0 for IPv6). However, even the largest organizations use only a small fraction of the global address space. Therefore, it makes sense to track in NetBox only the address space which is of interest to your organization.
Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the RFC 1918 private IPv4 space. So, you might define three aggregates for this space:
* 10.0.0.0/8
* 172.16.0.0/12
* 192.168.0.0/16
Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space.
Any prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation.
## RIRs
Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space.
Each aggregate must be assigned to one RIR. NetBox by default will be populated with the RIRs listed above, however you are free to remove these and/or create your own if you choose.
# Prefixes
A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address; all bits in the address not covered by the mask must be zero.
Each prefix may be assigned to one VRF; prefixes not assigned to a VRF are assigned to the "global" table. Prefixes are also organized under their respective aggregates, irrespective of VRF assignment.
A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. This can be helpful is replicating real-world IP assignments. Each prefix may also be assigned a short description.
## Status
Each prefix is assigned an operational status. A status describes very generally the state of a prefix within the network; for example, statuses might include:
* Active (provisioned)
* Reserved (for future use)
* Deprecated (no longer in use)
* Container (a summary of child prefixes)
NetBox provides several statuses by default, but you are free to change them to suit the needs of your organization.
## Role
Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include:
* Access segment
* Infrastructure
* NAT
* Lab
* Out-of-band
Role assignment is optional. And like statuses, you are free to create your own.
# IP Addresses
An IP address comprises a single address (either IPv4 or IPv6) and its mask. Its mask should match exactly how the IP address is configured on an interface in the real world.
Like prefixes, an IP address can optionally be assigned to a VRF (or it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. Each IP address can also be assigned a short description.
Each IP address can optionally be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address.
One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily is denoting the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not currently supported.
# VLAN
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.
Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.

View File

@ -0,0 +1,23 @@
"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data.
# Secret
A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a parent object with NetBox, such as a device. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names.
## Secret Roles
Each secret is assigned a functional role which indicates what it is used for. Typical roles might include:
* Login credentials
* SNMP community strings
* RADIUS/TACACS+ keys
* IKE key strings
* Routing protocol shared secrets
# User Keys
Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator, this user key will contain a unique, encrypted copy of the AES master key needed to retrieve secret data.
User keys may be created by users individually, however they are of no use until they have been activated by a user who already has access to retrieve secret data.

4145
docs/schema.sql Normal file

File diff suppressed because it is too large Load Diff

View File

30
netbox/circuits/admin.py Normal file
View File

@ -0,0 +1,30 @@
from django.contrib import admin
from .models import Provider, CircuitType, Circuit
@admin.register(Provider)
class ProviderAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'asn']
@admin.register(CircuitType)
class CircuitTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
@admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin):
list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id']
list_filter = ['provider']
exclude = ['interface']
def get_queryset(self, request):
qs = super(CircuitAdmin, self).get_queryset(request)
return qs.select_related('provider', 'type', 'site')

View File

View File

@ -0,0 +1,60 @@
from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
#
# Providers
#
class ProviderSerializer(serializers.ModelSerializer):
class Meta:
model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderNestedSerializer(ProviderSerializer):
class Meta(ProviderSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Circuit types
#
class CircuitTypeSerializer(serializers.ModelSerializer):
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
class CircuitTypeNestedSerializer(CircuitTypeSerializer):
class Meta(CircuitTypeSerializer.Meta):
pass
#
# Circuits
#
class CircuitSerializer(serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
site = SiteNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate',
'xconnect_id', 'comments']
class CircuitNestedSerializer(CircuitSerializer):
class Meta(CircuitSerializer.Meta):
fields = ['id', 'cid']

View File

@ -0,0 +1,24 @@
from django.conf.urls import url
from extras.models import GRAPH_TYPE_PROVIDER
from extras.api.views import GraphListView
from .views import *
urlpatterns = [
# Providers
url(r'^providers/$', ProviderListView.as_view(), name='provider_list'),
url(r'^providers/(?P<pk>\d+)/$', ProviderDetailView.as_view(), name='provider_detail'),
url(r'^providers/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'),
# Circuit types
url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
# Circuits
url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'),
url(r'^circuits/(?P<pk>\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'),
]

View File

@ -0,0 +1,54 @@
from rest_framework import generics
from circuits.models import Provider, CircuitType, Circuit
from circuits.filters import CircuitFilter
from .serializers import ProviderSerializer, CircuitTypeSerializer, CircuitSerializer
class ProviderListView(generics.ListAPIView):
"""
List all providers
"""
queryset = Provider.objects.all()
serializer_class = ProviderSerializer
class ProviderDetailView(generics.RetrieveAPIView):
"""
Retrieve a single provider
"""
queryset = Provider.objects.all()
serializer_class = ProviderSerializer
class CircuitTypeListView(generics.ListAPIView):
"""
List all circuit types
"""
queryset = CircuitType.objects.all()
serializer_class = CircuitTypeSerializer
class CircuitTypeDetailView(generics.RetrieveAPIView):
"""
Retrieve a single circuit type
"""
queryset = CircuitType.objects.all()
serializer_class = CircuitTypeSerializer
class CircuitListView(generics.ListAPIView):
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
serializer_class = CircuitSerializer
filter_class = CircuitFilter
class CircuitDetailView(generics.RetrieveAPIView):
"""
Retrieve a single circuit
"""
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
serializer_class = CircuitSerializer

View File

@ -0,0 +1,52 @@
import django_filters
from dcim.models import Site
from circuits.models import Provider, Circuit, CircuitType
class CircuitFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
provider_id = django_filters.ModelMultipleChoiceFilter(
name='provider',
queryset=Provider.objects.all(),
label='Provider (ID)',
)
provider = django_filters.ModelMultipleChoiceFilter(
name='provider',
queryset=Provider.objects.all(),
to_field_name='slug',
label='Provider (slug)',
)
type_id = django_filters.ModelMultipleChoiceFilter(
name='type',
queryset=CircuitType.objects.all(),
label='Circuit type (ID)',
)
type = django_filters.ModelMultipleChoiceFilter(
name='type',
queryset=CircuitType.objects.all(),
to_field_name='slug',
label='Circuit type (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = Circuit
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(cid__icontains=value)

191
netbox/circuits/forms.py Normal file
View File

@ -0,0 +1,191 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from utilities.forms import BootstrapMixin, SmallTextarea, ConfirmationForm, APISelect, Livesearch
from .models import PORT_SPEED_CHOICES, Circuit, Provider, CircuitType
from utilities.forms import CommentField, CSVDataField, BulkImportForm
#
# Providers
#
class ProviderForm(forms.ModelForm, BootstrapMixin):
comments = CommentField()
class Meta:
model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
widgets = {
'noc_contact': SmallTextarea(attrs={'rows': 5}),
'admin_contact': SmallTextarea(attrs={'rows': 5}),
}
help_texts = {
'name': "Full name of the provider",
'slug': "URL-friendly unique shorthand (e.g. 'decix' for DE-CIX)",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
}
class ProviderFromCSVForm(forms.ModelForm):
class Meta:
model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url']
class ProviderImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ProviderFromCSVForm)
class ProviderBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
asn = forms.IntegerField(required=False, label='ASN')
account = forms.CharField(max_length=30, required=False, label='Account number')
portal_url = forms.URLField(required=False, label='Portal')
noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact')
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField()
class ProviderBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
#
# Circuits
#
class CircuitForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
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')
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
comments = CommentField()
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
]
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'port_speed': "Physical circuit speed",
'commit_rate': "Commited rate (in Mbps)",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
def __init__(self, *args, **kwargs):
super(CircuitForm, self).__init__(*args, **kwargs)
# If this circuit has been assigned to an interface, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
self.fields['interface'].choices = [
(iface.id, {
'label': iface.name,
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
]
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id',
'pp_info']
class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
port_speed = forms.ChoiceField(choices=[(None, '---------')] + PORT_SPEED_CHOICES, required=False,
label='Port speed')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Mbps)')
comments = CommentField()
class CircuitBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, '{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
return [(p.slug, '{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, '{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
class CircuitFilterForm(forms.Form, BootstrapMixin):
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '__first__'),
]
operations = [
migrations.CreateModel(
name='Circuit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cid', models.CharField(max_length=50, verbose_name=b'Circuit ID')),
('install_date', models.DateField(blank=True, null=True, verbose_name=b'Date installed')),
('port_speed', models.PositiveSmallIntegerField(choices=[[100, b'100 Mbps'], [1000, b'1 Gbps'], [10000, b'10 Gbps'], [25000, b'25 Gbps'], [40000, b'40 Gbps'], [50000, b'50 Gbps'], [100000, b'100 Gbps']], verbose_name=b'Port speed')),
('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'Commit rate (Mbps)')),
('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
('comments', models.TextField(blank=True)),
('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='circuit', to='dcim.Interface')),
],
options={
'ordering': ['provider', 'cid'],
},
),
migrations.CreateModel(
name='CircuitType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Provider',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')),
('account', models.CharField(blank=True, max_length=30, verbose_name=b'Account number')),
('portal_url', models.URLField(blank=True, verbose_name=b'Portal')),
('noc_contact', models.TextField(blank=True, verbose_name=b'NOC Contact')),
('admin_contact', models.TextField(blank=True, verbose_name=b'Admin Contact')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='circuit',
name='provider',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider'),
),
migrations.AddField(
model_name='circuit',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='dcim.Site'),
),
migrations.AddField(
model_name='circuit',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType'),
),
migrations.AlterUniqueTogether(
name='circuit',
unique_together=set([('provider', 'cid')]),
),
]

View File

86
netbox/circuits/models.py Normal file
View File

@ -0,0 +1,86 @@
from django.core.urlresolvers import reverse
from django.db import models
from dcim.models import Site, Interface
PORT_SPEED_100M = 100
PORT_SPEED_1G = 1000
PORT_SPEED_10G = 10000
PORT_SPEED_25G = 25000
PORT_SPEED_40G = 40000
PORT_SPEED_50G = 50000
PORT_SPEED_100G = 100000
PORT_SPEED_CHOICES = [
[PORT_SPEED_100M, '100 Mbps'],
[PORT_SPEED_1G, '1 Gbps'],
[PORT_SPEED_10G, '10 Gbps'],
[PORT_SPEED_25G, '25 Gbps'],
[PORT_SPEED_40G, '40 Gbps'],
[PORT_SPEED_50G, '50 Gbps'],
[PORT_SPEED_100G, '100 Gbps'],
]
class Provider(models.Model):
"""
A transit provider, IX, or direct peer
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
account = models.CharField(max_length=30, blank=True, verbose_name='Account number')
portal_url = models.URLField(blank=True, verbose_name='Portal')
noc_contact = models.TextField(blank=True, verbose_name='NOC Contact')
admin_contact = models.TextField(blank=True, verbose_name='Admin Contact')
comments = models.TextField(blank=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:provider', args=[self.slug])
class CircuitType(models.Model):
"""
A type of circuit
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Circuit(models.Model):
"""
A data circuit from a site to a provider (includes IX connections)
"""
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
port_speed = models.PositiveSmallIntegerField(choices=PORT_SPEED_CHOICES, verbose_name='Port speed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Mbps)')
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
comments = models.TextField(blank=True)
class Meta:
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']
def __unicode__(self):
return "{0} {1}".format(self.provider, self.cid)
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])

59
netbox/circuits/tables.py Normal file
View File

@ -0,0 +1,59 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Circuit, Provider
#
# Providers
#
class ProviderTable(tables.Table):
name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
asn = tables.Column(verbose_name='ASN')
circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
class Meta:
model = Provider
fields = ('name', 'asn', 'circuit_count')
empty_text = "No providers found."
attrs = {
'class': 'table table-hover',
}
class ProviderBulkEditTable(ProviderTable):
pk = tables.CheckBoxColumn()
class Meta(ProviderTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'asn', 'circuit_count')
#
# Circuits
#
class CircuitTable(tables.Table):
cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
type = tables.Column(verbose_name='Type')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
port_speed = tables.Column(verbose_name='Port Speed')
commit_rate = tables.Column(verbose_name='Commit (Mbps)')
class Meta:
model = Circuit
fields = ('cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate')
empty_text = "No circuits found."
attrs = {
'class': 'table table-hover',
}
class CircuitBulkEditTable(CircuitTable):
pk = tables.CheckBoxColumn()
class Meta(CircuitTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate')

3
netbox/circuits/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

23
netbox/circuits/urls.py Normal file
View File

@ -0,0 +1,23 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^circuits/$', views.circuit_list, name='circuit_list'),
url(r'^circuits/add/$', views.circuit_add, name='circuit_add'),
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.circuit_edit, name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.circuit_delete, name='circuit_delete'),
url(r'^providers/$', views.provider_list, name='provider_list'),
url(r'^providers/add/$', views.provider_add, name='provider_add'),
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.provider_edit, name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.provider_delete, name='provider_delete'),
]

309
netbox/circuits/views.py Normal file
View File

@ -0,0 +1,309 @@
from django_tables2 import RequestConfig
from django.conf import settings
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.models import Count, ProtectedError
from django.shortcuts import get_object_or_404, redirect, render
from extras.models import ExportTemplate
from utilities.error_handlers import handle_protectederror
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkImportView, BulkEditView, BulkDeleteView
from .filters import CircuitFilter
from .forms import CircuitForm, CircuitImportForm, CircuitBulkEditForm, CircuitBulkDeleteForm, CircuitFilterForm, \
ProviderForm, ProviderImportForm, ProviderBulkEditForm, ProviderBulkDeleteForm
from .models import Circuit, Provider
from .tables import CircuitTable, CircuitBulkEditTable, ProviderTable, ProviderBulkEditTable
#
# Providers
#
def provider_list(request):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='provider', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_providers')
return response
if request.user.has_perm('circuits.change_provider') or request.user.has_perm('circuits.delete_provider'):
provider_table = ProviderBulkEditTable(queryset)
else:
provider_table = ProviderTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(provider_table)
export_templates = ExportTemplate.objects.filter(content_type__model='provider')
return render(request, 'circuits/provider_list.html', {
'provider_table': provider_table,
'export_templates': export_templates,
})
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
return render(request, 'circuits/provider.html', {
'provider': provider,
'circuits': circuits,
})
@permission_required('circuits.add_provider')
def provider_add(request):
if request.method == 'POST':
form = ProviderForm(request.POST)
if form.is_valid():
provider = form.save()
messages.success(request, "Added new provider: {0}".format(provider))
if '_addanother' in request.POST:
return redirect('circuits:provider_add')
else:
return redirect('circuits:provider', slug=provider.slug)
else:
form = ProviderForm()
return render(request, 'circuits/provider_edit.html', {
'form': form,
'cancel_url': reverse('circuits:provider_list'),
})
@permission_required('circuits.change_provider')
def provider_edit(request, slug):
provider = get_object_or_404(Provider, slug=slug)
if request.method == 'POST':
form = ProviderForm(request.POST, instance=provider)
if form.is_valid():
provider = form.save()
messages.success(request, "Modified provider {0}".format(provider))
return redirect('circuits:provider', slug=provider.slug)
else:
form = ProviderForm(instance=provider)
return render(request, 'circuits/provider_edit.html', {
'provider': provider,
'form': form,
'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug}),
})
@permission_required('circuits.delete_provider')
def provider_delete(request, slug):
provider = get_object_or_404(Provider, slug=slug)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
provider.delete()
messages.success(request, "Provider {0} has been deleted".format(provider))
return redirect('circuits:provider_list')
except ProtectedError, e:
handle_protectederror(provider, request, e)
return redirect('circuits:provider', slug=provider.slug)
else:
form = ConfirmationForm()
return render(request, 'circuits/provider_delete.html', {
'provider': provider,
'form': form,
'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug})
})
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider'
form = ProviderImportForm
table = ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
form = ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
redirect_url = 'circuits:provider_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} providers".format(updated_count))
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
form = ProviderBulkDeleteForm
template_name = 'circuits/provider_bulk_delete.html'
redirect_url = 'circuits:provider_list'
#
# Circuits
#
def circuit_list(request):
queryset = Circuit.objects.select_related('provider', 'type', 'site')
queryset = CircuitFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='circuit', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_circuits')
return response
if request.user.has_perm('circuits.change_circuit') or request.user.has_perm('circuits.delete_circuit'):
circuit_table = CircuitBulkEditTable(queryset)
else:
circuit_table = CircuitTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(circuit_table)
export_templates = ExportTemplate.objects.filter(content_type__model='circuit')
return render(request, 'circuits/circuit_list.html', {
'circuit_table': circuit_table,
'export_templates': export_templates,
'filter_form': CircuitFilterForm(request.GET, label_suffix=''),
})
def circuit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
})
@permission_required('circuits.add_circuit')
def circuit_add(request):
if request.method == 'POST':
form = CircuitForm(request.POST)
if form.is_valid():
circuit = form.save()
messages.success(request, "Added new circuit: {0}".format(circuit))
if '_addanother' in request.POST:
return redirect('circuits:circuit_add')
else:
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = CircuitForm(initial={
'site': request.GET.get('site'),
})
return render(request, 'circuits/circuit_edit.html', {
'form': form,
'cancel_url': reverse('circuits:circuit_list'),
})
@permission_required('circuits.change_circuit')
def circuit_edit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
if request.method == 'POST':
form = CircuitForm(request.POST, instance=circuit)
if form.is_valid():
circuit = form.save()
messages.success(request, "Modified circuit {0}".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = CircuitForm(instance=circuit)
return render(request, 'circuits/circuit_edit.html', {
'circuit': circuit,
'form': form,
'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk}),
})
@permission_required('circuits.delete_circuit')
def circuit_delete(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
circuit.delete()
messages.success(request, "Circuit {0} has been deleted".format(circuit))
return redirect('circuits:circuit_list')
except ProtectedError, e:
handle_protectederror(circuit, request, e)
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = ConfirmationForm()
return render(request, 'circuits/circuit_delete.html', {
'circuit': circuit,
'form': form,
'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk})
})
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit'
form = CircuitImportForm
table = CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
form = CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
redirect_url = 'circuits:circuit_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} circuits".format(updated_count))
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
form = CircuitBulkDeleteForm
template_name = 'circuits/circuit_bulk_delete.html'
redirect_url = 'circuits:circuit_list'

1
netbox/dcim/__init__.py Normal file
View File

@ -0,0 +1 @@
default_app_config = 'dcim.apps.IPAMConfig'

161
netbox/dcim/admin.py Normal file
View File

@ -0,0 +1,161 @@
from django.contrib import admin
from django.db.models import Count
from .models import *
@admin.register(Site)
class SiteAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'facility', 'asn']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(RackGroup)
class RackGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Rack)
class RackAdmin(admin.ModelAdmin):
list_display = ['name', 'facility_id', 'site', 'u_height']
#
# Device types
#
@admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
class ConsolePortTemplateAdmin(admin.TabularInline):
model = ConsolePortTemplate
class ConsoleServerPortTemplateAdmin(admin.TabularInline):
model = ConsoleServerPortTemplate
class PowerPortTemplateAdmin(admin.TabularInline):
model = PowerPortTemplate
class PowerOutletTemplateAdmin(admin.TabularInline):
model = PowerOutletTemplate
class InterfaceTemplateAdmin(admin.TabularInline):
model = InterfaceTemplate
@admin.register(DeviceType)
class DeviceTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['model'],
}
inlines = [
ConsolePortTemplateAdmin,
ConsoleServerPortTemplateAdmin,
PowerPortTemplateAdmin,
PowerOutletTemplateAdmin,
InterfaceTemplateAdmin,
]
list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
'power_outlets', 'interfaces']
list_filter = ['manufacturer']
def get_queryset(self, request):
return DeviceType.objects.annotate(
console_port_count=Count('console_port_templates', distinct=True),
cs_port_count=Count('cs_port_templates', distinct=True),
power_port_count=Count('power_port_templates', distinct=True),
power_outlet_count=Count('power_outlet_templates', distinct=True),
interface_count=Count('interface_templates', distinct=True),
)
def console_ports(self, instance):
return instance.console_port_count
def console_server_ports(self, instance):
return instance.cs_port_count
def power_ports(self, instance):
return instance.power_port_count
def power_outlets(self, instance):
return instance.power_outlet_count
def interfaces(self, instance):
return instance.interface_count
#
# Devices
#
@admin.register(DeviceRole)
class DeviceRoleAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'color']
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'rpc_client']
class ConsolePortAdmin(admin.TabularInline):
model = ConsolePort
readonly_fields = ['cs_port']
class ConsoleServerPortAdmin(admin.TabularInline):
model = ConsoleServerPort
class PowerPortAdmin(admin.TabularInline):
model = PowerPort
readonly_fields = ['power_outlet']
class PowerOutletAdmin(admin.TabularInline):
model = PowerOutlet
class InterfaceAdmin(admin.TabularInline):
model = Interface
class ModuleAdmin(admin.TabularInline):
model = Module
@admin.register(Device)
class DeviceAdmin(admin.ModelAdmin):
inlines = [
ConsolePortAdmin,
ConsoleServerPortAdmin,
PowerPortAdmin,
PowerOutletAdmin,
InterfaceAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
list_filter = ['device_role']
def get_queryset(self, request):
qs = super(DeviceAdmin, self).get_queryset(request)
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip', 'rack')

View File

View File

@ -0,0 +1,6 @@
from rest_framework.exceptions import APIException
class MissingFilterException(APIException):
status_code = 400
default_detail = "One or more required filters is missing from the request."

View File

@ -0,0 +1,300 @@
from rest_framework import serializers
from ipam.models import IPAddress
from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort,\
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, RACK_FACE_FRONT, RACK_FACE_REAR
#
# Sites
#
class SiteSerializer(serializers.ModelSerializer):
class Meta:
model = Site
fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
class SiteNestedSerializer(SiteSerializer):
class Meta(SiteSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Rack groups
#
class RackGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
class Meta:
model = RackGroup
fields = ['id', 'name', 'slug', 'site']
class RackGroupNestedSerializer(SiteSerializer):
class Meta(SiteSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Racks
#
class RackSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
site = SiteNestedSerializer()
group = RackGroupNestedSerializer()
class Meta:
model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments']
def get_display_name(self, obj):
return str(obj)
class RackNestedSerializer(RackSerializer):
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name']
class RackDetailSerializer(RackSerializer):
front_units = serializers.SerializerMethodField()
rear_units = serializers.SerializerMethodField()
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units',
'rear_units']
def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT)
for u in units:
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
return units
def get_rear_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_REAR)
for u in units:
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
return units
#
# Manufacturers
#
class ManufacturerSerializer(serializers.ModelSerializer):
class Meta:
model = Manufacturer
fields = ['id', 'name', 'slug']
class ManufacturerNestedSerializer(ManufacturerSerializer):
class Meta(ManufacturerSerializer.Meta):
pass
#
# Device types
#
class DeviceTypeSerializer(serializers.ModelSerializer):
manufacturer = ManufacturerNestedSerializer()
class Meta:
model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device']
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug']
#
# Device roles
#
class DeviceRoleSerializer(serializers.ModelSerializer):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color']
class DeviceRoleNestedSerializer(DeviceRoleSerializer):
class Meta(DeviceRoleSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Platforms
#
class PlatformSerializer(serializers.ModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'rpc_client']
class PlatformNestedSerializer(PlatformSerializer):
class Meta(PlatformSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Devices
#
# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency
class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
class Meta:
model = IPAddress
fields = ['id', 'family', 'address']
class DeviceSerializer(serializers.ModelSerializer):
device_type = DeviceTypeNestedSerializer()
device_role = DeviceRoleNestedSerializer()
platform = PlatformNestedSerializer()
rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer()
class Meta:
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
'face', 'status', 'primary_ip', 'ro_snmp', 'comments']
class DeviceNestedSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
model = Device
fields = ['id', 'name']
#
# Console server ports
#
class ConsoleServerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class Meta:
model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_console']
class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
class Meta(ConsoleServerPortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Console ports
#
class ConsolePortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
cs_port = ConsoleServerPortNestedSerializer()
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
class ConsolePortNestedSerializer(ConsolePortSerializer):
class Meta(ConsolePortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Power outlets
#
class PowerOutletSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class Meta:
model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_port']
class PowerOutletNestedSerializer(PowerOutletSerializer):
class Meta(PowerOutletSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Power ports
#
class PowerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
power_outlet = PowerOutletNestedSerializer()
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
class PowerPortNestedSerializer(PowerPortSerializer):
class Meta(PowerPortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Interfaces
#
class InterfaceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
class Meta:
model = Interface
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected']
class InterfaceNestedSerializer(InterfaceSerializer):
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name']
class InterfaceDetailSerializer(InterfaceSerializer):
connected_interface = InterfaceSerializer(source='get_connected_interface')
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected',
'connected_interface']
#
# Interface connections
#
class InterfaceConnectionSerializer(serializers.ModelSerializer):
class Meta:
model = InterfaceConnection
fields = ['id', 'interface_a', 'interface_b', 'connection_status']

581
netbox/dcim/api/tests.py Normal file
View File

@ -0,0 +1,581 @@
import json
from rest_framework import status
from rest_framework.test import APITestCase
class SiteTest(APITestCase):
fixtures = [
'dcim',
'ipam',
'extras',
]
standard_fields = [
'id',
'name',
'slug',
'facility',
'asn',
'physical_address',
'shipping_address',
'comments',
'count_prefixes',
'count_vlans',
'count_racks',
'count_devices',
'count_circuits'
]
nested_fields = [
'id',
'name',
'slug'
]
rack_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments'
]
graph_fields = [
'name',
'embed_url',
'link',
]
def test_get_list(self, endpoint='/api/dcim/sites/'):
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(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/sites/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
)
# Check Nested Serializer.
self.assertEqual(
sorted(i.get('site').keys()),
sorted(self.nested_fields),
)
def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
)
class RackTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
nested_fields = [
'id',
'name',
'facility_id',
'display_name'
]
standard_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments'
]
detail_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments',
'front_units',
'rear_units'
]
def test_get_list(self, endpoint='/api/dcim/racks/'):
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(self.standard_fields),
)
self.assertEqual(
sorted(i.get('site').keys()),
sorted(SiteTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/racks/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('site').keys()),
sorted(SiteTest.nested_fields),
)
class ManufacturersTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
standard_fields = [
'id',
'name',
'slug',
]
nested_fields = standard_fields
def test_get_list(self, endpoint='/api/dcim/manufacturers/'):
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(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTypeTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'manufacturer',
'model',
'slug',
'u_height',
'is_console_server',
'is_pdu',
'is_network_device'
]
nested_fields = [
'id',
'manufacturer',
'model',
'slug'
]
def test_get_list(self, endpoint='/api/dcim/device-types/'):
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(self.standard_fields),
)
def test_detail_list(self, endpoint='/api/dcim/device-types/1/'):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
# sorted(self.standard_fields),
# )
# self.assertEqual(
# sorted(content.get('manufacturer').keys()),
# sorted(ManufacturersTest.nested_fields),
# )
pass
class DeviceRolesTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'color']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/device-roles/'):
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(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class PlatformsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'rpc_client']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/platforms/'):
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(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/platforms/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'name',
'display_name',
'device_type',
'device_role',
'platform',
'serial',
'rack',
'position',
'face',
'status',
'primary_ip',
'ro_snmp',
'comments',
]
nested_fields = ['id', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
sorted(device.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(device.get('device_type')),
sorted(DeviceTypeTest.nested_fields),
)
self.assertEqual(
sorted(device.get('device_role')),
sorted(DeviceRolesTest.nested_fields),
)
if device.get('platform'):
self.assertEqual(
sorted(device.get('platform')),
sorted(PlatformsTest.nested_fields),
)
self.assertEqual(
sorted(device.get('rack')),
sorted(RackTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/devices/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class ConsoleServerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_console']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
class ConsolePortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
self.assertEqual(
sorted(console_port.get('cs_port')),
sorted(ConsoleServerPortsTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'):
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(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerOutletsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_port']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'):
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(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
class InterfaceTest(APITestCase):
fixtures = ['dcim', 'ipam', 'extras']
standard_fields = [
'id',
'device',
'name',
'form_factor',
'mgmt_only',
'description',
'is_connected'
]
nested_fields = ['id', 'device', 'name']
detail_fields = [
'id',
'device',
'name',
'form_factor',
'mgmt_only',
'description',
'is_connected',
'connected_interface'
]
connection_fields = [
'id',
'interface_a',
'interface_b',
'connection_status',
]
def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'):
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(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
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),
)
def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.connection_fields),
)
class RelatedConnectionsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'device',
'console-ports',
'power-ports',
'interfaces',
]
def test_get_list(self, endpoint=(
'/api/dcim/related-connections/'
'?peer-device=test1-edge1&peer-interface=xe-0/0/3')):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)

66
netbox/dcim/api/urls.py Normal file
View File

@ -0,0 +1,66 @@
from django.conf.urls import url
from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.api.views import GraphListView
from .views import *
urlpatterns = [
# Sites
url(r'^sites/$', SiteListView.as_view(), name='site_list'),
url(r'^sites/(?P<pk>\d+)/$', SiteDetailView.as_view(), name='site_detail'),
url(r'^sites/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'),
url(r'^sites/(?P<site>\d+)/racks/$', RackListView.as_view(), name='site_racks'),
# Rack groups
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
# Racks
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
# Manufacturers
url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),
# Device types
url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'),
url(r'^device-types/(?P<pk>\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'),
# Device roles
url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/(?P<pk>\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'),
# Platforms
url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/(?P<pk>\d+)/$', PlatformDetailView.as_view(), name='platform_detail'),
# Devices
url(r'^devices/$', DeviceListView.as_view(), name='device_list'),
url(r'^devices/(?P<pk>\d+)/$', DeviceDetailView.as_view(), name='device_detail'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'),
url(r'^devices/(?P<pk>\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(), name='device_consoleserverports'),
url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
# Console ports
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
# Power ports
url(r'^power-ports/(?P<pk>\d+)/$', PowerPortView.as_view(), name='powerport'),
# Interfaces
url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'),
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'),
# Miscellaneous
url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
]

438
netbox/dcim/api/views.py Normal file
View File

@ -0,0 +1,438 @@
from rest_framework import generics
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from django.conf import settings
from django.http import Http404
from django.shortcuts import get_object_or_404
from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort, \
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, IFACE_FF_VIRTUAL
from dcim.filters import RackGroupFilter, RackFilter, DeviceTypeFilter, DeviceFilter, InterfaceFilter
from .exceptions import MissingFilterException
from .serializers import SiteSerializer, RackGroupSerializer, RackSerializer, RackDetailSerializer, \
ManufacturerSerializer, DeviceTypeSerializer, DeviceRoleSerializer, PlatformSerializer, DeviceSerializer, \
DeviceNestedSerializer, ConsolePortSerializer, ConsoleServerPortSerializer, PowerPortSerializer, \
PowerOutletSerializer, InterfaceSerializer, InterfaceDetailSerializer, InterfaceConnectionSerializer
from extras.api.renderers import BINDZoneRenderer
from utilities.api import ServiceUnavailable
#
# Sites
#
class SiteListView(generics.ListAPIView):
"""
List all sites
"""
queryset = Site.objects.all()
serializer_class = SiteSerializer
class SiteDetailView(generics.RetrieveAPIView):
"""
Retrieve a single site
"""
queryset = Site.objects.all()
serializer_class = SiteSerializer
#
# Rack groups
#
class RackGroupListView(generics.ListAPIView):
"""
List all rack groups
"""
queryset = RackGroup.objects.all()
serializer_class = RackGroupSerializer
filter_class = RackGroupFilter
class RackGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack group
"""
queryset = RackGroup.objects.all()
serializer_class = RackGroupSerializer
#
# Racks
#
class RackListView(generics.ListAPIView):
"""
List racks (filterable)
"""
queryset = Rack.objects.select_related('site')
serializer_class = RackSerializer
filter_class = RackFilter
class RackDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack
"""
queryset = Rack.objects.select_related('site')
serializer_class = RackDetailSerializer
#
# Rack units
#
class RackUnitListView(APIView):
"""
List rack units (by rack)
"""
def get(self, request, pk):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
elevation = rack.get_rack_units(face)
# Serialize Devices within the rack elevation
for u in elevation:
if u['device']:
u['device'] = DeviceNestedSerializer(instance=u['device']).data
return Response(elevation)
#
# Manufacturers
#
class ManufacturerListView(generics.ListAPIView):
"""
List all hardware manufacturers
"""
queryset = Manufacturer.objects.all()
serializer_class = ManufacturerSerializer
class ManufacturerDetailView(generics.RetrieveAPIView):
"""
Retrieve a single hardware manufacturers
"""
queryset = Manufacturer.objects.all()
serializer_class = ManufacturerSerializer
#
# Device Types
#
class DeviceTypeListView(generics.ListAPIView):
"""
List device types (filterable)
"""
queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = DeviceTypeSerializer
filter_class = DeviceTypeFilter
class DeviceTypeDetailView(generics.ListAPIView):
"""
Retrieve a single device type
"""
queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = DeviceTypeSerializer
#
# Device roles
#
class DeviceRoleListView(generics.ListAPIView):
"""
List all device roles
"""
queryset = DeviceRole.objects.all()
serializer_class = DeviceRoleSerializer
class DeviceRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single device role
"""
queryset = DeviceRole.objects.all()
serializer_class = DeviceRoleSerializer
#
# Platforms
#
class PlatformListView(generics.ListAPIView):
"""
List all platforms
"""
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PlatformDetailView(generics.RetrieveAPIView):
"""
Retrieve a single platform
"""
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
#
# Devices
#
class DeviceListView(generics.ListAPIView):
"""
List devices (filterable)
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
.prefetch_related('primary_ip__nat_outside')
serializer_class = DeviceSerializer
filter_class = DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer]
class DeviceDetailView(generics.RetrieveAPIView):
"""
Retrieve a single device
"""
queryset = Device.objects.all()
serializer_class = DeviceSerializer
#
# Console ports
#
class ConsolePortListView(generics.ListAPIView):
"""
List console ports (by device)
"""
serializer_class = ConsolePortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return ConsolePort.objects.filter(device=device).select_related('cs_port')
class ConsolePortView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = ConsolePortSerializer
queryset = ConsolePort.objects.all()
#
# Console server ports
#
class ConsoleServerPortListView(generics.ListAPIView):
"""
List console server ports (by device)
"""
serializer_class = ConsoleServerPortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
#
# Power ports
#
class PowerPortListView(generics.ListAPIView):
"""
List power ports (by device)
"""
serializer_class = PowerPortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return PowerPort.objects.filter(device=device).select_related('power_outlet')
class PowerPortView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = PowerPortSerializer
queryset = PowerPort.objects.all()
#
# Power outlets
#
class PowerOutletListView(generics.ListAPIView):
"""
List power outlets (by device)
"""
serializer_class = PowerOutletSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return PowerOutlet.objects.filter(device=device).select_related('connected_port')
#
# Interfaces
#
class InterfaceListView(generics.ListAPIView):
"""
List interfaces (by device)
"""
serializer_class = InterfaceSerializer
filter_class = InterfaceFilter
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')
# Filter by type (physical or virtual)
iface_type = self.request.query_params.get('type')
if iface_type == 'physical':
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
elif iface_type == 'virtual':
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
elif iface_type is not None:
queryset = queryset.empty()
return queryset
class InterfaceDetailView(generics.RetrieveAPIView):
"""
Retrieve a single interface
"""
queryset = Interface.objects.select_related('device')
serializer_class = InterfaceDetailSerializer
class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = InterfaceConnectionSerializer
queryset = InterfaceConnection.objects.all()
#
# Live queries
#
class LLDPNeighborsView(APIView):
"""
Retrieve live LLDP neighbors of a device
"""
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
if not device.primary_ip:
raise ServiceUnavailable(detail="No IP configured for this device.")
hostname = str(device.primary_ip.address.ip)
RPC = device.get_rpc_client()
if not RPC:
raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform))
# Connect to device and retrieve inventory info
try:
with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
lldp_neighbors = rpc_client.get_lldp_neighbors()
except:
raise ServiceUnavailable(detail="Error connecting to the remote device.")
return Response(lldp_neighbors)
#
# Miscellaneous
#
class RelatedConnectionsView(APIView):
"""
Retrieve all connections related to a given console/power/interface connection
"""
def get(self, request):
peer_device = request.GET.get('peer-device')
peer_interface = request.GET.get('peer-interface')
# Search by interface
if peer_device and peer_interface:
# Determine local interface from peer interface's connection
try:
peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
except Interface.DoesNotExist:
raise Http404()
local_iface = peer_iface.get_connected_interface()
if local_iface:
device = local_iface.device
else:
return Response()
else:
raise MissingFilterException(detail='Must specify search parameters (peer-device and peer-interface).')
# Initialize response skeleton
response = dict()
response['device'] = DeviceSerializer(device).data
response['console-ports'] = []
response['power-ports'] = []
response['interfaces'] = []
# Build console connections
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
for cp in console_ports:
cp_info = dict()
cp_info['name'] = cp.name
if cp.cs_port:
cp_info['console-server'] = cp.cs_port.device.name
cp_info['port'] = cp.cs_port.name
else:
cp_info['console-server'] = None
cp_info['port'] = None
response['console-ports'].append(cp_info)
# Build power connections
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
for pp in power_ports:
pp_info = dict()
pp_info['name'] = pp.name
if pp.power_outlet:
pp_info['pdu'] = pp.power_outlet.device.name
pp_info['outlet'] = pp.power_outlet.name
else:
pp_info['pdu'] = None
pp_info['outlet'] = None
response['power-ports'].append(pp_info)
# Built interface connections
interfaces = Interface.objects.filter(device=device)
for iface in interfaces:
iface_info = dict()
iface_info['name'] = iface.name
peer_interface = iface.get_connected_interface()
if peer_interface:
iface_info['device'] = peer_interface.device.name
iface_info['interface'] = peer_interface.name
else:
iface_info['device'] = None
iface_info['interface'] = None
response['interfaces'].append(iface_info)
return Response(response)

6
netbox/dcim/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"

317
netbox/dcim/filters.py Normal file
View File

@ -0,0 +1,317 @@
import django_filters
from django.db.models import Q
from .models import Site, RackGroup, Rack, Manufacturer, DeviceType, DeviceRole, Device, ConsolePort, \
ConsoleServerPort, Platform, PowerPort, PowerOutlet, Interface, InterfaceConnection
class RackGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = RackGroup
fields = ['site_id', 'site']
class RackFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group (slug)',
)
class Meta:
model = Rack
fields = ['q', 'site_id', 'site', 'u_height']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value)
)
class DeviceTypeFilter(django_filters.FilterSet):
manufacturer_id = django_filters.ModelChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
class Meta:
model = DeviceType
fields = ['manufacturer_id', 'manufacturer', 'model', 'u_height', 'is_console_server', 'is_pdu',
'is_network_device']
class DeviceFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='device_role',
queryset=DeviceRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='device_role',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
device_type = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
label='Device type (ID)',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
to_field_name='slug',
label='Device model (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = django_filters.ModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
is_console_server = django_filters.BooleanFilter(
name='device_type__is_console_server',
label='Is a console server',
)
is_pdu = django_filters.BooleanFilter(
name='device_type__is_pdu',
label='Is a PDU',
)
is_network_device = django_filters.BooleanFilter(
name='device_type__is_network_device',
label='Is a network device',
)
class Meta:
model = Device
fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type', 'manufacturer_id',
'manufacturer', 'model', 'platform_id', 'platform', 'is_console_server', 'is_pdu',
'is_network_device']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value) |
Q(modules__serial__icontains=value)
).distinct()
class ConsolePortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = ConsolePort
fields = ['device_id', 'device', 'name']
class ConsoleServerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = ConsoleServerPort
fields = ['device_id', 'device', 'name']
class PowerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = PowerPort
fields = ['device_id', 'device', 'name']
class PowerOutletFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = PowerOutlet
fields = ['device_id', 'device', 'name']
class InterfaceFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = Interface
fields = ['device_id', 'device', 'name']
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = ConsoleServerPort
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(cs_port__device__rack__site__slug=value)
class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = PowerOutlet
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(power_outlet__device__rack__site__slug=value)
class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = InterfaceConnection
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(interface_a__device__rack__site__slug=value) |
Q(interface_b__device__rack__site__slug=value)
)

File diff suppressed because it is too large Load Diff

953
netbox/dcim/forms.py Normal file
View File

@ -0,0 +1,953 @@
import re
from django import forms
from django.db.models import Count, Q
from ipam.models import IPAddress
from utilities.forms import BootstrapMixin, SmallTextarea, SelectWithDisabled, ConfirmationForm, APISelect, \
Livesearch, CSVDataField, CommentField, BulkImportForm, FlexibleModelChoiceField, ExpandableNameField
from .models import Site, Rack, RackGroup, Device, Manufacturer, DeviceType, DeviceRole, Platform, ConsolePort, \
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, CONNECTION_STATUS_CHOICES, \
CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, IFACE_FF_VIRTUAL, STATUS_CHOICES
BULK_STATUS_CHOICES = [
['', '---------'],
]
BULK_STATUS_CHOICES += STATUS_CHOICES
DEVICE_BY_PK_RE = '{\d+\}'
def get_device_by_name_or_pk(name):
"""
Attempt to retrieve a device by either its name or primary key ('{pk}').
"""
if re.match(DEVICE_BY_PK_RE, name):
pk = name.strip('{}')
device = Device.objects.get(pk=pk)
else:
device = Device.objects.get(name=name)
return device
#
# Sites
#
class SiteForm(forms.ModelForm, BootstrapMixin):
comments = CommentField()
class Meta:
model = Site
fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
'shipping_address': SmallTextarea(attrs={'rows': 3}),
}
help_texts = {
'name': "Full name of the site",
'slug': "URL-friendly unique shorthand (e.g. 'nyc3' for NYC3)",
'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number",
'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address"
}
class SiteFromCSVForm(forms.ModelForm):
class Meta:
model = Site
fields = ['name', 'slug', 'facility', 'asn']
class SiteImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SiteFromCSVForm)
#
# Racks
#
class RackForm(forms.ModelForm, BootstrapMixin):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
))
comments = CommentField()
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
help_texts = {
'site': "The site at which the rack exists",
'name': "Organizational rack name",
'facility_id': "The unique rack ID assigned by the facility",
'u_height': "Height in rack units",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
}
def __init__(self, *args, **kwargs):
super(RackForm, self).__init__(*args, **kwargs)
# Limit rack group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Group not found.'})
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height']
def clean(self):
site = self.cleaned_data.get('site')
group = self.cleaned_data.get('group')
# Validate device type
if site and group:
try:
self.instance.group = RackGroup.objects.get(site=site, name=group)
except RackGroup.DoesNotExist:
self.add_error('group', "Invalid rack group ({})".format(group))
class RackImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=RackFromCSVForm)
class RackBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
u_height = forms.IntegerField(required=False, label='Height (U)')
comments = CommentField()
class RackBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks'))
return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
return [(g.slug, '{} > {} ({})'.format(g.site.name, g.name, g.rack_count)) for g in group_choices]
class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group = forms.MultipleChoiceField(required=False, choices=rack_group_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Devices
#
class DeviceForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'position'}
))
position = forms.TypedChoiceField(required=False, empty_value=None, widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device',
))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
widget=forms.Select(attrs={'filter-for': 'device_type'}))
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Model', widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
))
comments = CommentField()
class Meta:
model = Device
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip', 'ro_snmp', 'comments']
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
'ro_snmp': "Read-only SNMP string",
}
widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}),
'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
}
def __init__(self, *args, **kwargs):
super(DeviceForm, self).__init__(*args, **kwargs)
if self.instance.pk:
# Initialize helper selections
self.initial['site'] = self.instance.rack.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of IPs assigned to this device
primary_ip_choices = []
interface_ips = IPAddress.objects.filter(interface__device=self.instance)
primary_ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
nat_ips = IPAddress.objects.filter(nat_inside__interface__device=self.instance)\
.select_related('nat_inside__interface')
primary_ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip'].choices = [(None, '---------')] + primary_ip_choices
else:
# An object that doesn't exist yet can't have any IPs assigned to it
self.fields['primary_ip'].choices = []
self.fields['primary_ip'].widget.attrs['readonly'] = True
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Rack position
try:
if self.is_bound and self.data.get('rack') and self.data.get('face') is not None:
position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face'))
elif self.initial.get('rack') and self.initial.get('face') is not None:
position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face'))
else:
position_choices = []
except Rack.DoesNotExist:
position_choices = []
self.fields['position'].choices = [('', '---------')] + [
(p['id'], {
'label': p['name'],
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
}) for p in position_choices
]
# Limit device_type choices
if self.is_bound:
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
.select_related('manufacturer')
elif self.initial.get('manufacturer'):
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
.select_related('manufacturer')
else:
self.fields['device_type'].choices = []
class DeviceFromCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'})
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'})
model_name = forms.CharField()
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'})
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
})
rack_name = forms.CharField()
face = forms.ChoiceField(choices=[('front', 'Front'), ('rear', 'Rear')])
class Meta:
model = Device
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
'position', 'face']
def clean(self):
manufacturer = self.cleaned_data.get('manufacturer')
model_name = self.cleaned_data.get('model_name')
site = self.cleaned_data.get('site')
rack_name = self.cleaned_data.get('rack_name')
# Validate device type
if manufacturer and model_name:
try:
self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({})".format(model_name))
# Validate rack
if site and rack_name:
try:
self.instance.rack = Rack.objects.get(site=site, name=rack_name)
except Rack.DoesNotExist:
self.add_error('rack_name', "Invalid rack ({})".format(rack_name))
def clean_face(self):
face = self.cleaned_data['face']
if face.lower() == 'front':
return 0
if face.lower() == 'rear':
return 1
raise forms.ValidationError("Invalid rack face ({})".format(face))
class DeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=DeviceFromCSVForm)
class DeviceBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
status = forms.ChoiceField(choices=BULK_STATUS_CHOICES, required=False, initial='', label='Status')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
ro_snmp = forms.CharField(max_length=50, required=False, label='SNMP (RO)')
class DeviceBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.slug, '{} ({})'.format(t, t.device_count)) for t in type_choices]
def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
return [(p.slug, '{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
class DeviceFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
type = forms.MultipleChoiceField(required=False, choices=device_type_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
#
# Console ports
#
class ConsolePortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = ConsolePort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class ConsolePortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class ConsoleConnectionCSVForm(forms.Form):
console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True),
to_field_name='name',
error_messages={'invalid_choice': 'Console server not found'})
cs_port = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate console server port
if self.cleaned_data.get('console_server'):
try:
cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'],
name=self.cleaned_data['cs_port'])
if ConsolePort.objects.filter(cs_port=cs_port):
raise forms.ValidationError("Console server port is already occupied (by {} {})"
.format(cs_port.connected_console.device, cs_port.connected_console))
except ConsoleServerPort.DoesNotExist:
raise forms.ValidationError("Invalid console server port ({} {})"
.format(self.cleaned_data['console_server'], self.cleaned_data['cs_port']))
# Validate console port
if self.cleaned_data.get('device'):
try:
console_port = ConsolePort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['console_port'])
if console_port.cs_port:
raise forms.ValidationError("Console port is already connected (to {} {})"
.format(console_port.cs_port.device, console_port.cs_port))
except ConsolePort.DoesNotExist:
raise forms.ValidationError("Invalid console port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['console_port']))
class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
console_port = ConsolePort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['console_port'])
console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'],
name=form.cleaned_data['cs_port'])
if form.cleaned_data['status'] == 'planned':
console_port.connection_status = CONNECTION_STATUS_PLANNED
else:
console_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(console_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'console_server'}))
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
)
cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
disabled_indicator='connected_console'))
class Meta:
model = ConsolePort
fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
labels = {
'cs_port': 'Port',
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(ConsolePortConnectionForm, self).__init__(*args, **kwargs)
if not self.instance.pk:
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.fields['cs_port'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize console server choices
if self.is_bound and self.data.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True)
elif self.initial.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True)
else:
self.fields['console_server'].choices = []
# Initialize CS port choices
if self.is_bound:
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.data['console_server'])
elif self.initial.get('console_server', None):
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.initial['console_server'])
else:
self.fields['cs_port'].choices = []
#
# Console server ports
#
class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = ConsoleServerPort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(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}}',
attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/',
disabled_indicator='cs_port'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
def __init__(self, consoleserverport, *args, **kwargs):
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Initialize port choices
if self.is_bound:
self.fields['port'].queryset = ConsolePort.objects.filter(device__pk=self.data['device'])
elif self.initial.get('device', None):
self.fields['port'].queryset = ConsolePort.objects.filter(device_pk=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Power ports
#
class PowerPortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = PowerPort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class PowerPortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class PowerConnectionCSVForm(forms.Form):
pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name',
error_messages={'invalid_choice': 'PDU not found.'})
power_outlet = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate power outlet
if self.cleaned_data.get('pdu'):
try:
power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'],
name=self.cleaned_data['power_outlet'])
if PowerPort.objects.filter(power_outlet=power_outlet):
raise forms.ValidationError("Power outlet is already occupied (by {} {})"
.format(power_outlet.connected_console.device,
power_outlet.connected_console))
except PowerOutlet.DoesNotExist:
raise forms.ValidationError("Invalid PDU port ({} {})"
.format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet']))
# Validate power port
if self.cleaned_data.get('device'):
try:
power_port = PowerPort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['power_port'])
if power_port.power_outlet:
raise forms.ValidationError("Power port is already connected (to {} {})"
.format(power_port.power_outlet.device, power_port.power_outlet))
except PowerPort.DoesNotExist:
raise forms.ValidationError("Invalid power port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['power_port']))
class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=PowerConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
power_port = PowerPort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['power_port'])
power_port.cs_port = PowerOutlet.objects.get(device=form.cleaned_data['pdu'],
name=form.cleaned_data['power_outlet'])
if form.cleaned_data['status'] == 'planned':
power_port.connection_status = CONNECTION_STATUS_PLANNED
else:
power_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(power_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'pdu'}))
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
)
power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet',
widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
disabled_indicator='connected_port'))
class Meta:
model = PowerPort
fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
labels = {
'power_outlet': 'Outlet',
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(PowerPortConnectionForm, self).__init__(*args, **kwargs)
if not self.instance.pk:
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.fields['power_outlet'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize PDU choices
if self.is_bound and self.data.get('rack'):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True)
elif self.initial.get('rack', None):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True)
else:
self.fields['pdu'].choices = []
# Initialize power outlet choices
if self.is_bound:
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.data['pdu'])
elif self.initial.get('pdu', None):
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.initial['pdu'])
else:
self.fields['power_outlet'].choices = []
#
# Power outlets
#
class PowerOutletForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = PowerOutlet
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class PowerOutletCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(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}}',
attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/',
disabled_indicator='power_outlet'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
def __init__(self, poweroutlet, *args, **kwargs):
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Initialize port choices
if self.is_bound:
self.fields['port'].queryset = PowerPort.objects.filter(device__pk=self.data['device'])
elif self.initial.get('device', None):
self.fields['port'].queryset = PowerPort.objects.filter(device_pk=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Interfaces
#
class InterfaceForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Interface
fields = ['device', 'name', 'form_factor', 'mgmt_only', 'description']
widgets = {
'device': forms.HiddenInput(),
}
class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
#
# Interface connections
#
class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device_b'}))
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
)
interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
class Meta:
model = InterfaceConnection
fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
def __init__(self, device_a, *args, **kwargs):
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site)
# Initialize interface A choices
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \
.select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface_a'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
]
# Initialize device_b choices if rack_b is set
if self.is_bound and self.data.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
elif self.initial.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
else:
self.fields['device_b'].choices = []
# Initialize interface_b choices if device_b is set
if self.is_bound:
device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
elif self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
else:
device_b_interfaces = []
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces
]
class InterfaceConnectionCSVForm(forms.Form):
device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device A not found.'})
interface_a = forms.CharField()
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'})
interface_b = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate interface A
if self.cleaned_data.get('device_a'):
try:
interface_a = Interface.objects.get(device=self.cleaned_data['device_a'],
name=self.cleaned_data['interface_a'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
except InterfaceConnection.DoesNotExist:
pass
# Validate interface B
if self.cleaned_data.get('device_b'):
try:
interface_b = Interface.objects.get(device=self.cleaned_data['device_b'],
name=self.cleaned_data['interface_b'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
except InterfaceConnection.DoesNotExist:
pass
class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
name=form.cleaned_data['interface_a'])
interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
name=form.cleaned_data['interface_b'])
connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
if form.cleaned_data['status'] == 'planned':
connection.connection_status = CONNECTION_STATUS_PLANNED
else:
connection.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(connection)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
confirm = forms.BooleanField(required=True)
# Used for HTTP redirect upon successful deletion
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
#
# Connections
#
class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class PowerConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
#
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'interface', 'set_as_primary']
help_texts = {
'address': 'IPv4 or IPv6 address (with mask)'
}
def __init__(self, device, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
self.fields['interface'].queryset = device.interfaces.all()
self.fields['interface'].required = True
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary
if not IPAddress.objects.filter(interface__device=device).count():
self.fields['set_as_primary'].initial = True

View File

@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ConsolePort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='ConsolePortTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='ConsoleServerPort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
),
migrations.CreateModel(
name='ConsoleServerPortTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Device',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', utilities.fields.NullableCharField(blank=True, max_length=50, null=True, unique=True)),
('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')),
('position', models.PositiveSmallIntegerField(blank=True, help_text=b'Number of the lowest U position occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)')),
('face', models.PositiveSmallIntegerField(blank=True, choices=[[0, b'Front'], [1, b'Rear']], null=True, verbose_name=b'Rack face')),
('status', models.BooleanField(choices=[[True, b'Active'], [False, b'Offline']], default=True, verbose_name=b'Status')),
('ro_snmp', models.CharField(blank=True, max_length=50, verbose_name=b'SNMP (RO)')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='DeviceRole',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='DeviceType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model', models.CharField(max_length=50)),
('slug', models.SlugField()),
('u_height', models.PositiveSmallIntegerField(default=1, verbose_name=b'Height (U)')),
('is_full_depth', models.BooleanField(default=True, help_text=b'Device consumes both front and rear rack faces', verbose_name=b'Is full depth')),
('is_console_server', models.BooleanField(default=False, help_text=b'Include this type of device in lists of console servers', verbose_name=b'Is a console server')),
('is_pdu', models.BooleanField(default=False, help_text=b'Include this type of device in lists of PDUs', verbose_name=b'Is a PDU')),
('is_network_device', models.BooleanField(default=True, help_text=b'This is a network device (e.g. switch, router, etc.)', verbose_name=b'Is a network device')),
],
options={
'ordering': ['manufacturer', 'model'],
},
),
migrations.CreateModel(
name='Interface',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)),
('mgmt_only', models.BooleanField(default=False, verbose_name=b'OOB Management')),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='InterfaceConnection',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('connection_status', models.BooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True, verbose_name=b'Status')),
('interface_a', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_a', to='dcim.Interface')),
('interface_b', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_b', to='dcim.Interface')),
],
),
migrations.CreateModel(
name='InterfaceTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)),
('mgmt_only', models.BooleanField(default=False, verbose_name=b'Management only')),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interface_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Manufacturer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name=b'Name')),
('part_id', models.CharField(blank=True, max_length=50, verbose_name=b'Part ID')),
('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='Platform',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('rpc_client', models.CharField(blank=True, choices=[[b'juniper-junos', b'Juniper Junos (NETCONF)'], [b'cisco-ios', b'Cisco IOS (SSH)'], [b'opengear', b'Opengear (SSH)']], max_length=30, verbose_name=b'RPC client')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PowerOutlet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_outlets', to='dcim.Device')),
],
),
migrations.CreateModel(
name='PowerOutletTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_outlet_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='PowerPort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_ports', to='dcim.Device')),
('power_outlet', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_port', to='dcim.PowerOutlet')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='PowerPortTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_port_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Rack',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('facility_id', utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name=b'Facility ID')),
('u_height', models.PositiveSmallIntegerField(default=42, verbose_name=b'Height (U)')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.CreateModel(
name='RackGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('slug', models.SlugField()),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('facility', models.CharField(blank=True, max_length=50)),
('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')),
('physical_address', models.CharField(blank=True, max_length=200)),
('shipping_address', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='rackgroup',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rack_groups', to='dcim.Site'),
),
migrations.AddField(
model_name='rack',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='racks', to='dcim.RackGroup'),
),
migrations.AddField(
model_name='rack',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.Site'),
),
migrations.AddField(
model_name='devicetype',
name='manufacturer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_types', to='dcim.Manufacturer'),
),
migrations.AddField(
model_name='device',
name='device_role',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.DeviceRole'),
),
migrations.AddField(
model_name='device',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='device',
name='platform',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='dcim.Platform'),
),
]

View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '0001_initial'),
('ipam', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='device',
name='primary_ip',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for', to='ipam.IPAddress', verbose_name=b'Primary IP'),
),
migrations.AddField(
model_name='device',
name='rack',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_port_templates', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='consoleserverport',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_ports', to='dcim.Device'),
),
migrations.AddField(
model_name='consoleporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_port_templates', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='consoleport',
name='cs_port',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name=b'Console server port'),
),
migrations.AddField(
model_name='consoleport',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_ports', to='dcim.Device'),
),
migrations.AlterUniqueTogether(
name='rackgroup',
unique_together=set([('site', 'name'), ('site', 'slug')]),
),
migrations.AlterUniqueTogether(
name='rack',
unique_together=set([('site', 'facility_id'), ('site', 'name')]),
),
migrations.AlterUniqueTogether(
name='powerporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='powerport',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='poweroutlettemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='poweroutlet',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='module',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='interfacetemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='interface',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='devicetype',
unique_together=set([('manufacturer', 'slug'), ('manufacturer', 'model')]),
),
migrations.AlterUniqueTogether(
name='device',
unique_together=set([('rack', 'position', 'face')]),
),
migrations.AlterUniqueTogether(
name='consoleserverporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleserverport',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleport',
unique_together=set([('device', 'name')]),
),
]

View File

686
netbox/dcim/models.py Normal file
View File

@ -0,0 +1,686 @@
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, ObjectDoesNotExist
from extras.rpc import RPC_CLIENTS
from secrets.models import Secret
from utilities.fields import NullableCharField
RACK_FACE_FRONT = 0
RACK_FACE_REAR = 1
RACK_FACE_CHOICES = [
[RACK_FACE_FRONT, 'Front'],
[RACK_FACE_REAR, 'Rear'],
]
COLOR_TEAL = 'teal'
COLOR_GREEN = 'green'
COLOR_BLUE = 'blue'
COLOR_PURPLE = 'purple'
COLOR_YELLOW = 'yellow'
COLOR_ORANGE = 'orange'
COLOR_RED = 'red'
COLOR_GRAY1 = 'light_gray'
COLOR_GRAY2 = 'medium_gray'
COLOR_GRAY3 = 'dark_gray'
DEVICE_ROLE_COLOR_CHOICES = [
[COLOR_TEAL, 'Teal'],
[COLOR_GREEN, 'Green'],
[COLOR_BLUE, 'Blue'],
[COLOR_PURPLE, 'Purple'],
[COLOR_YELLOW, 'Yellow'],
[COLOR_ORANGE, 'Orange'],
[COLOR_RED, 'Red'],
[COLOR_GRAY1, 'Light Gray'],
[COLOR_GRAY2, 'Medium Gray'],
[COLOR_GRAY3, 'Dark Gray'],
]
IFACE_FF_VIRTUAL = 0
IFACE_FF_100M_COPPER = 800
IFACE_FF_1GE_COPPER = 1000
IFACE_FF_SFP = 1100
IFACE_FF_SFP_PLUS = 1200
IFACE_FF_XFP = 1300
IFACE_FF_QSFP_PLUS = 1400
IFACE_FF_CHOICES = [
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_100M_COPPER, '10/100M (Copper)'],
[IFACE_FF_1GE_COPPER, '1GE (Copper)'],
[IFACE_FF_SFP, '1GE (SFP)'],
[IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
[IFACE_FF_XFP, '10GE (XFP)'],
[IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
]
STATUS_ACTIVE = True
STATUS_OFFLINE = False
STATUS_CHOICES = [
[STATUS_ACTIVE, 'Active'],
[STATUS_OFFLINE, 'Offline'],
]
CONNECTION_STATUS_PLANNED = False
CONNECTION_STATUS_CONNECTED = True
CONNECTION_STATUS_CHOICES = [
[CONNECTION_STATUS_PLANNED, 'Planned'],
[CONNECTION_STATUS_CONNECTED, 'Connected'],
]
# For mapping platform -> NC client
RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos'
RPC_CLIENT_CISCO_IOS = 'cisco-ios'
RPC_CLIENT_OPENGEAR = 'opengear'
RPC_CLIENT_CHOICES = [
[RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'],
[RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'],
[RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'],
]
class Site(models.Model):
"""
A physical site
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
facility = models.CharField(max_length=50, blank=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:site', args=[self.slug])
@property
def count_prefixes(self):
return self.prefixes.count()
@property
def count_vlans(self):
return self.vlans.count()
@property
def count_racks(self):
return Rack.objects.filter(site=self).count()
@property
def count_devices(self):
return Device.objects.filter(rack__site=self).count()
@property
def count_circuits(self):
return self.circuits.count()
class RackGroup(models.Model):
"""
An arbitrary grouping of Racks; e.g. a building or room.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups')
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
def __unicode__(self):
return self.name
class Rack(models.Model):
"""
An equipment rack within a site (e.g. a 48U rack)
"""
name = models.CharField(max_length=50)
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
comments = models.TextField(blank=True)
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'facility_id'],
]
def __unicode__(self):
if self.facility_id:
return "{} ({})".format(self.name, self.facility_id)
return self.name
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@property
def units(self):
return reversed(range(1, self.u_height + 1))
def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False):
"""
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
:param face: Rack face (front or rear)
:param remove_redundant: If True, rack units occupied by a device already listed will be omitted
"""
elevation = OrderedDict()
for u in reversed(range(1, self.u_height + 1)):
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
# Add devices to rack units list
if self.pk:
for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
.filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)):
if remove_redundant:
elevation[device.position]['device'] = device
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
else:
for u in range(device.position, device.position + device.device_type.u_height):
elevation[u]['device'] = device
return [u for u in elevation.values()]
def get_front_elevation(self):
return self.get_rack_units(face=RACK_FACE_FRONT, remove_redundant=True)
def get_rear_elevation(self):
return self.get_rack_units(face=RACK_FACE_REAR, remove_redundant=True)
def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
"""
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
position to another within a rack).
:param u_height: Minimum number of contiguous free units required
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
"""
# Gather all devices which consume U space within the rack
devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
# Remove units consumed by installed devices
for d in devices:
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
for u in range(d.position, d.position + d.device_type.u_height):
try:
units.remove(u)
except ValueError:
# Found overlapping devices in the rack!
pass
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(range(u, u + u_height)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
def get_0u_devices(self):
return self.devices.filter(position=0)
#
# Device Types
#
class Manufacturer(models.Model):
"""
A hardware manufacturer
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class DeviceType(models.Model):
"""
A unique hardware type; manufacturer and model number (e.g. Juniper EX4300-48T)
"""
manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT)
model = models.CharField(max_length=50)
slug = models.SlugField()
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")
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="Include this type of device in lists of console servers")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
help_text="Include this type of device in lists of PDUs")
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
help_text="This is a network device (e.g. switch, router, etc.)")
class Meta:
ordering = ['manufacturer', 'model']
unique_together = [
['manufacturer', 'model'],
['manufacturer', 'slug'],
]
def __unicode__(self):
return "{0} {1}".format(self.manufacturer, self.model)
class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
#
# Devices
#
class DeviceRole(models.Model):
"""
The functional role of a device (e.g. router, switch, console server, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Platform(models.Model):
"""
A class of software running on a hardware device (e.g. Juniper Junos or Cisco IOS)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client')
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Device(models.Model):
"""
A physical piece of equipment mounted within a rack
"""
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', help_text='Number of the lowest U position occupied by the device')
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IP')
ro_snmp = models.CharField(max_length=50, blank=True, verbose_name='SNMP (RO)')
comments = models.TextField(blank=True)
secrets = GenericRelation(Secret)
class Meta:
ordering = ['name']
unique_together = ['rack', 'position', 'face']
def __unicode__(self):
return self.display_name
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@property
def display_name(self):
if self.name:
return self.name
elif self.position:
return "{} ({} U{})".format(self.device_type, self.rack, self.position)
else:
return "{} ({})".format(self.device_type, self.rack)
def clean(self):
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError("Must specify rack face with rack position.")
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height))
except Rack.DoesNotExist:
pass
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
super(Device, self).save(*args, **kwargs)
# If this is a new Device, instantiate all of the related components per the DeviceType definition
if is_new:
ConsolePort.objects.bulk_create(
[ConsolePort(device=self, name=template.name) for template in self.device_type.console_port_templates.all()]
)
ConsoleServerPort.objects.bulk_create(
[ConsoleServerPort(device=self, name=template.name) for template in self.device_type.cs_port_templates.all()]
)
PowerPort.objects.bulk_create(
[PowerPort(device=self, name=template.name) for template in self.device_type.power_port_templates.all()]
)
PowerOutlet.objects.bulk_create(
[PowerOutlet(device=self, name=template.name) for template in self.device_type.power_outlet_templates.all()]
)
Interface.objects.bulk_create(
[Interface(device=self, name=template.name, form_factor=template.form_factor, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
)
def get_rpc_client(self):
"""
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
"""
if not self.platform:
return None
return RPC_CLIENTS.get(self.platform.rpc_client)
class ConsolePort(models.Model):
"""
A physical console port on a device
"""
device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class ConsoleServerPortManager(models.Manager):
def get_queryset(self):
"""
Include the trailing numeric portion of each port name to allow for proper ordering.
For example:
Port 1, Port 2, Port 3 ... Port 9, Port 10, Port 11 ...
Instead of:
Port 1, Port 10, Port 11 ... Port 19, Port 2, Port 20 ...
"""
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
'name_as_integer': "CAST(substring(dcim_consoleserverport.name FROM '[0-9]+$') AS INTEGER)",
}).order_by('device', 'name_as_integer')
class ConsoleServerPort(models.Model):
"""
A physical port on a console server
"""
device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
objects = ConsoleServerPortManager()
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class PowerPort(models.Model):
"""
A physical power supply (intake) port on a device
"""
device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class PowerOutletManager(models.Manager):
def get_queryset(self):
return super(PowerOutletManager, self).get_queryset().extra(select={
'name_padded': "CONCAT(SUBSTRING(dcim_poweroutlet.name FROM '^[^0-9]+'), LPAD(SUBSTRING(dcim_poweroutlet.name FROM '[0-9\/]+$'), 8, '0'))",
}).order_by('device', 'name_padded')
class PowerOutlet(models.Model):
"""
A physical power outlet (output) port on a device
"""
device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
objects = PowerOutletManager()
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class InterfaceManager(models.Manager):
def get_queryset(self):
"""
Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that
interfaces are ordered numerically without regard to type. For example:
xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ...
instead of:
et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ...
"""
return super(InterfaceManager, self).get_queryset().extra(select={
'_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)",
'_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)",
'_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)",
}).order_by('device', '_id1', '_id2', '_id3')
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)
class Interface(models.Model):
"""
A physical data interface on a device
"""
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management')
description = models.CharField(max_length=100, blank=True)
objects = InterfaceManager()
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL
@property
def is_connected(self):
try:
return bool(self.circuit)
except ObjectDoesNotExist:
pass
return bool(self.connection)
@property
def connection(self):
try:
return self.connected_as_a
except ObjectDoesNotExist:
pass
try:
return self.connected_as_b
except ObjectDoesNotExist:
pass
return None
def get_connected_interface(self):
try:
connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self))
if connection.interface_a == self:
return connection.interface_b
else:
return connection.interface_a
except InterfaceConnection.DoesNotExist:
return None
except InterfaceConnection.MultipleObjectsReturned as e:
raise e("Multiple connections found for {0} interface {1}!".format(self.device, self))
class InterfaceConnection(models.Model):
"""
A symmetrical, one-to-one connection between two device interfaces
"""
interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE)
interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE)
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, verbose_name='Status')
class Module(models.Model):
"""
A hardware module belonging to a device. Used for inventory purposes only.
"""
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name

165
netbox/dcim/tables.py Normal file
View File

@ -0,0 +1,165 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Site, Rack, Device, ConsolePort, PowerPort
PREFIXES_PER_VLAN = """
{% for p in record.prefix_set.all %}
<a href="{% url 'ipam:prefix' pk=p.pk %}">{{ p }}</a>
{% if not forloop.last %}<br />{% endif %}
{% endfor %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.status.get_bootstrap_class_display|lower }}">
{{ record.status.name }}
</span>
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}</a>
"""
#
# Sites
#
class SiteTable(tables.Table):
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility')
asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
class Meta:
model = Site
fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', 'circuit_count')
empty_text = "No sites have been defined."
attrs = {
'class': 'table table-hover',
}
#
# Racks
#
class RackTable(tables.Table):
name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(verbose_name='Group')
facility_id = tables.Column(verbose_name='Facility ID')
u_height = tables.Column(verbose_name='Height (U)')
devices = tables.Column(accessor=Accessor('device_count'), orderable=False, verbose_name='Devices')
class Meta:
model = Rack
fields = ('name', 'site', 'group', 'facility_id', 'u_height')
empty_text = "No racks were found."
attrs = {
'class': 'table table-hover',
}
class RackBulkEditTable(RackTable):
pk = tables.CheckBoxColumn()
class Meta(RackTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height')
#
# Devices
#
class DeviceTable(tables.Table):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.Column(verbose_name='Role')
device_type = tables.Column(verbose_name='Type')
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address', template_code="{{ record.primary_ip.address.ip }}")
class Meta:
model = Device
fields = ('name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
empty_text = "No devices were found."
attrs = {
'class': 'table table-hover',
}
class DeviceBulkEditTable(DeviceTable):
pk = tables.CheckBoxColumn()
class Meta(DeviceTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
class DeviceImportTable(tables.Table):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
site = tables.Column(accessor=Accessor('rack.site'), 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')
device_type = tables.Column(verbose_name='Type')
class Meta:
model = Device
fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
attrs = {
'class': 'table table-hover',
}
#
# Device connections
#
class ConsoleConnectionTable(tables.Table):
console_server = tables.LinkColumn('dcim:device', accessor=Accessor('cs_port.device'), args=[Accessor('cs_port.device.pk')], verbose_name='Console server')
cs_port = tables.Column(verbose_name='Port')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
class Meta:
model = ConsolePort
fields = ('console_server', 'cs_port', 'device', 'name')
attrs = {
'class': 'table table-hover',
}
class PowerConnectionTable(tables.Table):
pdu = tables.LinkColumn('dcim:device', accessor=Accessor('power_outlet.device'), args=[Accessor('power_outlet.device.pk')], verbose_name='PDU')
power_outlet = tables.Column(verbose_name='Outlet')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
class Meta:
model = PowerPort
fields = ('pdu', 'power_outlet', 'device', 'name')
attrs = {
'class': 'table table-hover',
}
class InterfaceConnectionTable(tables.Table):
device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
interface_a = tables.Column(verbose_name='Interface A')
device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
interface_b = tables.Column(verbose_name='Interface B')
class Meta:
model = PowerPort
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
attrs = {
'class': 'table table-hover',
}

View File

View File

@ -0,0 +1,71 @@
from django.test import TestCase
from dcim.forms import *
from dcim.models import *
def get_id(model, slug):
return model.objects.get(slug=slug).id
class DeviceTestCase(TestCase):
fixtures = ['dcim', 'ipam']
def test_racked_device(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'leaf-switch'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_FRONT,
'platform': get_id(Platform, 'juniper-junos'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'position': 41,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'juniper'),
})
self.assertTrue(test.is_valid(), test.fields['position'].choices)
self.assertTrue(test.save())
def test_racked_device_occupied(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'leaf-switch'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_FRONT,
'platform': get_id(Platform, 'juniper-junos'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'position': 1,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'juniper'),
})
self.assertFalse(test.is_valid())
def test_non_racked_device(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'pdu'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': None,
'platform': None,
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'position': None,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'servertech'),
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
def test_non_racked_device_with_face(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'pdu'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_REAR,
'platform': None,
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'position': None,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'servertech'),
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())

View File

@ -0,0 +1,96 @@
from django.test import TestCase
from dcim.models import *
class RackTestCase(TestCase):
def setUp(self):
site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
self.device_type = {
'ff2048': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
),
'cc5000': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='CurrentCatapult 5000',
slug='cc5000',
u_height=0
),
}
self.role = {
'Server': DeviceRole.objects.create(
name='Server',
slug='server',
),
'Switch': DeviceRole.objects.create(
name='Switch',
slug='switch',
),
'Console Server': DeviceRole.objects.create(
name='Console Server',
slug='console-server',
),
'PDU': DeviceRole.objects.create(
name='PDU',
slug='pdu',
),
}
def test_mount_single_device(self):
rack1 = Rack.objects.get(name='TestRack1')
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
rack=rack1,
position=10,
face=RACK_FACE_REAR,
)
device1.save()
# Validate rack height
self.assertEqual(list(rack1.units), list(reversed(range(1, 43))))
# Validate inventory (front face)
rack1_inventory_front = rack1.get_front_elevation()
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
del(rack1_inventory_front[-10])
for u in rack1_inventory_front:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = rack1.get_rear_elevation()
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
del(rack1_inventory_rear[-10])
for u in rack1_inventory_rear:
self.assertIsNone(u['device'])
def test_mount_zero_ru(self):
pdu = Device.objects.create(
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
rack=self.rack,
position=None,
face=None,
)
self.assertTrue(pdu)

86
netbox/dcim/urls.py Normal file
View File

@ -0,0 +1,86 @@
from django.conf.urls import url
from secrets.views import secret_add
from . import views
urlpatterns = [
# Sites
url(r'^sites/$', views.site_list, name='site_list'),
url(r'^sites/add/$', views.site_add, name='site_add'),
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.site_edit, name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.site_delete, name='site_delete'),
# Racks
url(r'^racks/$', views.rack_list, name='rack_list'),
url(r'^racks/add/$', views.rack_add, name='rack_add'),
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.rack_edit, name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.rack_delete, name='rack_delete'),
# Devices
url(r'^devices/$', views.device_list, name='device_list'),
url(r'^devices/add/$', views.device_add, name='device_add'),
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.device_edit, name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.device_delete, name='device_delete'),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<parent_pk>\d+)/add-secret/$', secret_add, {'parent_model': 'dcim.Device'},
name='device_addsecret'),
# Console ports
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'),
# Console server ports
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'),
# Power ports
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.powerport_delete, name='powerport_delete'),
# Power outlets
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
# Console/power/interface connections
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'),
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Interfaces
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_bulk_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'),
]

1444
netbox/dcim/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

13
netbox/extras/admin.py Normal file
View File

@ -0,0 +1,13 @@
from django.contrib import admin
from .models import Graph, ExportTemplate
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['content_type', 'name', 'mime_type', 'file_extension']

View File

View File

@ -0,0 +1,31 @@
from rest_framework import renderers
# IP address family designations
AF = {
4: 'A',
6: 'AAAA',
}
class BINDZoneRenderer(renderers.BaseRenderer):
"""
Generate a BIND zone file from a list of DNS records.
Required fields: `name`, `primary_ip`
"""
media_type = 'text/plain'
format = 'bind-zone'
def render(self, data, media_type=None, renderer_context=None):
records = []
for record in data:
if record.get('name') and record.get('primary_ip'):
try:
records.append("{} IN {} {}".format(
record['name'],
AF[record['primary_ip']['family']],
record['primary_ip']['address'].split('/')[0],
))
except KeyError:
pass
return '\n'.join(records)

View File

@ -0,0 +1,14 @@
from rest_framework import serializers
from extras.models import Graph
class GraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
class Meta:
model = Graph
fields = ['name', 'embed_url', 'link']
def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object'])

View File

@ -0,0 +1,33 @@
from rest_framework import generics
from django.http import Http404
from django.shortcuts import get_object_or_404
from circuits.models import Provider
from dcim.models import Site, Interface
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE
from .serializers import GraphSerializer
class GraphListView(generics.ListAPIView):
"""
Returns a list of relevant graphs
"""
serializer_class = GraphSerializer
def get_serializer_context(self):
cls = {
GRAPH_TYPE_INTERFACE: Interface,
GRAPH_TYPE_PROVIDER: Provider,
GRAPH_TYPE_SITE: Site,
}
context = super(GraphListView, self).get_serializer_context()
context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])})
return context
def get_queryset(self):
graph_type = self.kwargs.get('type', None)
if not graph_type:
raise Http404()
queryset = Graph.objects.filter(type=graph_type)
return queryset

View File

@ -0,0 +1,12 @@
- model: extras.graph
pk: 1
fields: {type: 300, weight: 1000, name: Site Test Graph, source: 'http://localhost/na.png',
link: ''}
- model: extras.graph
pk: 2
fields: {type: 200, weight: 1000, name: Provider Test Graph, source: 'http://localhost/provider_graph.png',
link: ''}
- model: extras.graph
pk: 3
fields: {type: 100, weight: 1000, name: Interface Test Graph, source: 'http://localhost/interface_graph.png',
link: ''}

View File

View File

@ -0,0 +1,117 @@
from Exscript.protocols.Exception import LoginFailure
from getpass import getpass
from ncclient.transport.errors import AuthenticationError
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from dcim.models import Device, Module, Site
class Command(BaseCommand):
help = "Update inventory information for specified devices"
username = settings.NETBOX_USERNAME
password = settings.NETBOX_PASSWORD
def add_arguments(self, parser):
parser.add_argument('-u', '--username', dest='username', help="Specify the username to use")
parser.add_argument('-p', '--password', action='store_true', default=False, help="Prompt for password to use")
parser.add_argument('-s', '--site', dest='site', action='append', help="Filter devices by site (include argument once per site)")
parser.add_argument('-n', '--name', dest='name', help="Filter devices by name (regular expression)")
parser.add_argument('--full', action='store_true', default=False, help="For inventory update for all devices")
parser.add_argument('--fake', action='store_true', default=False, help="Do not actually update database")
def handle(self, *args, **options):
# Credentials
if options['username']:
self.username = options['username']
if options['password']:
self.password = getpass("Password: ")
device_list = Device.objects.filter()
# --site: Include only devices belonging to specified site(s)
if options['site']:
sites = Site.objects.filter(slug__in=options['site'])
if sites:
site_names = [s.name for s in sites]
self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names)))
else:
raise CommandError("One or more sites specified but none found.")
device_list = device_list.filter(rack__site__in=sites)
# --name: Filter devices by name matching a regex
if options['name']:
device_list = device_list.filter(name__iregex=options['name'])
# --full: Gather inventory data for *all* devices
if options['full']:
self.stdout.write("WARNING: Running inventory for all devices! Prior data will be overwritten. (--full)")
# --fake: Gathering data but not updating the database
if options['fake']:
self.stdout.write("WARNING: Inventory data will not be saved! (--fake)")
device_count = device_list.count()
self.stdout.write("** Found {} devices...".format(device_count))
for i, device in enumerate(device_list, start=1):
self.stdout.write("[{}/{}] {}: ".format(i, device_count, device.name), ending='')
# Skip inactive devices
if not device.status:
self.stdout.write("Skipped (inactive)")
continue
# Skip devices without primary_ip set
if not device.primary_ip:
self.stdout.write("Skipped (no primary IP set)")
continue
# Skip devices which have already been inventoried if not doing a full update
if device.serial and not options['full']:
self.stdout.write("Skipped (Serial: {})".format(device.serial))
continue
RPC = device.get_rpc_client()
if not RPC:
self.stdout.write("Skipped (no RPC client available for platform {})".format(device.platform))
continue
# Connect to device and retrieve inventory info
try:
with RPC(device, self.username, self.password) as rpc_client:
inventory = rpc_client.get_inventory()
except KeyboardInterrupt:
raise
except (AuthenticationError, LoginFailure):
self.stdout.write("Authentication error!")
continue
except Exception as e:
self.stdout.write("Error for {} ({}): {}".format(device, device.primary_ip.address.ip, e))
continue
self.stdout.write("")
self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial']))
self.stdout.write("\tDescription: {}".format(inventory['chassis']['description']))
for module in inventory['modules']:
self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'], module['serial']))
if not options['fake']:
with transaction.atomic():
if inventory['chassis']['serial']:
device.serial = inventory['chassis']['serial']
device.save()
Module.objects.filter(device=device).delete()
modules = []
for module in inventory['modules']:
modules.append(Module(device=device,
name=module['name'],
part_id=module['part_id'],
serial=module['serial']))
Module.objects.bulk_create(modules)
self.stdout.write("Finished!")

View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='ExportTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('template_code', models.TextField()),
('mime_type', models.CharField(blank=True, max_length=15)),
('file_extension', models.CharField(blank=True, max_length=15)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
options={
'ordering': ['content_type', 'name'],
},
),
migrations.CreateModel(
name='Graph',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.PositiveSmallIntegerField(choices=[(100, b'Interface'), (200, b'Provider'), (300, b'Site')])),
('weight', models.PositiveSmallIntegerField(default=1000)),
('name', models.CharField(max_length=100, verbose_name=b'Name')),
('source', models.CharField(max_length=500, verbose_name=b'Source URL')),
('link', models.URLField(blank=True, verbose_name=b'Link URL')),
],
options={
'ordering': ['type', 'weight', 'name'],
},
),
migrations.AlterUniqueTogether(
name='exporttemplate',
unique_together=set([('content_type', 'name')]),
),
]

View File

70
netbox/extras/models.py Normal file
View File

@ -0,0 +1,70 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200
GRAPH_TYPE_SITE = 300
GRAPH_TYPE_CHOICES = (
(GRAPH_TYPE_INTERFACE, 'Interface'),
(GRAPH_TYPE_PROVIDER, 'Provider'),
(GRAPH_TYPE_SITE, 'Site'),
)
EXPORTTEMPLATE_MODELS = [
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',
'aggregate', 'prefix', 'ipaddress', 'vlan',
'provider', 'circuit'
]
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
name = models.CharField(max_length=100, verbose_name='Name')
source = models.CharField(max_length=500, verbose_name='Source URL')
link = models.URLField(verbose_name='Link URL', blank=True)
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
return self.name
def embed_url(self, obj):
template = Template(self.source)
return template.render(Context({'obj': obj}))
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=200)
template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True)
file_extension = models.CharField(max_length=15, blank=True)
class Meta:
ordering = ['content_type', 'name']
unique_together = [
['content_type', 'name']
]
def __unicode__(self):
return "{}: {}".format(self.content_type, self.name)
def to_response(self, context_dict, filename):
"""
Render the template to an HTTP response, delivered as a named file attachment
"""
template = Template(self.template_code)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
response = HttpResponse(
template.render(Context(context_dict)),
content_type=mime_type
)
if self.file_extension:
filename += '.{}'.format(self.file_extension)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response

247
netbox/extras/rpc.py Normal file
View File

@ -0,0 +1,247 @@
from Exscript import Account
from Exscript.protocols import SSH2
from ncclient import manager
import paramiko
import re
import xmltodict
CONNECT_TIMEOUT = 5 # seconds
class RPCClient(object):
def __init__(self, device, username='', password=''):
self.username = username
self.password = password
try:
self.host = str(device.primary_ip.address.ip)
except AttributeError:
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
def get_lldp_neighbors(self):
"""
Returns a list of dictionaries, each representing an LLDP neighbor adjacency.
{
'local-interface': <str>,
'name': <str>,
'remote-interface': <str>,
'chassis-id': <str>,
}
"""
raise NotImplementedError("Feature not implemented for this platform.")
def get_inventory(self):
"""
Returns a dictionary representing the device chassis and installed modules.
{
'chassis': {
'serial': <str>,
'description': <str>,
}
'modules': [
{
'name': <str>,
'part_id': <str>,
'serial': <str>,
},
...
]
}
"""
raise NotImplementedError("Feature not implemented for this platform.")
class JunosNC(RPCClient):
"""
NETCONF client for Juniper Junos devices
"""
def __enter__(self):
# Initiate a connection to the device
self.manager = manager.connect(host=self.host, username=self.username, password=self.password,
hostkey_verify=False, timeout=CONNECT_TIMEOUT)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.manager.close_session()
def get_lldp_neighbors(self):
rpc_reply = self.manager.dispatch('get-lldp-neighbors-information')
lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information']
result = []
for neighbor_raw in lldp_neighbors_raw:
neighbor = dict()
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
neighbor['name'] = neighbor_raw.get('lldp-remote-system-name')
neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present
try:
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
except KeyError:
# Older versions of Junos report on interface ID instead of description
neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id')
neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id')
result.append(neighbor)
return result
def get_inventory(self):
rpc_reply = self.manager.dispatch('get-chassis-inventory')
inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis']
result = dict()
# Gather chassis data
result['chassis'] = {
'serial': inventory_raw['serial-number'],
'description': inventory_raw['description'],
}
# Gather modules
result['modules'] = []
for module in inventory_raw['chassis-module']:
try:
# Skip built-in modules
if module['name'] and module['serial-number'] != inventory_raw['serial-number']:
result['modules'].append({
'name': module['name'],
'part_id': module['model-number'] or '',
'serial': module['serial-number'] or '',
})
except KeyError:
pass
return result
class IOSSSH(RPCClient):
"""
SSH client for Cisco IOS devices
"""
def __enter__(self):
# Initiate a connection to the device
self.ssh = SSH2(connect_timeout=CONNECT_TIMEOUT)
self.ssh.connect(self.host)
self.ssh.login(Account(self.username, self.password))
# Disable terminal paging
self.ssh.execute("terminal length 0")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.ssh.send("exit\r")
self.ssh.close()
def get_inventory(self):
result = dict()
# Gather chassis data
try:
self.ssh.execute("show version")
show_version = self.ssh.response
serial = re.search("Processor board ID ([^\s]+)", show_version).groups()[0]
description = re.search("\r\n\r\ncisco ([^\s]+)", show_version).groups()[0]
except:
raise RuntimeError("Failed to glean chassis info from device.")
result['chassis'] = {
'serial': serial,
'description': description,
}
# Gather modules
result['modules'] = []
try:
self.ssh.execute("show inventory")
show_inventory = self.ssh.response
# Split modules on double line
modules_raw = show_inventory.strip().split('\r\n\r\n')
for module_raw in modules_raw:
try:
m_name = re.search('NAME: "([^"]+)"', module_raw).group(1)
m_pid = re.search('PID: ([^\s]+)', module_raw).group(1)
m_serial = re.search('SN: ([^\s]+)', module_raw).group(1)
# Omit built-in modules and those with no PID
if m_serial != result['chassis']['serial'] and m_pid.lower() != 'unspecified':
result['modules'].append({
'name': m_name,
'part_id': m_pid,
'serial': m_serial,
})
except AttributeError:
continue
except:
raise RuntimeError("Failed to glean module info from device.")
return result
class OpengearSSH(RPCClient):
"""
SSH client for Opengear devices
"""
def __enter__(self):
# Initiate a connection to the device
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
self.ssh.connect(self.host, username=self.username, password=self.password, timeout=CONNECT_TIMEOUT)
except paramiko.AuthenticationException:
# Try default Opengear credentials if the configured creds don't work
self.ssh.connect(self.host, username='root', password='default')
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.ssh.close()
def get_inventory(self):
try:
stdin, stdout, stderr = self.ssh.exec_command("showserial")
serial = stdout.readlines()[0].strip()
except:
raise RuntimeError("Failed to glean chassis serial from device.")
# Older models don't provide serial info
if serial == "No serial number information available":
serial = ''
try:
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
description = stdout.readlines()[0].split(' ', 1)[1].strip()
except:
raise RuntimeError("Failed to glean chassis description from device.")
return {
'chassis': {
'serial': serial,
'description': description,
},
'modules': [],
}
# For mapping platform -> NC client
RPC_CLIENTS = {
'juniper-junos': JunosNC,
'cisco-ios': IOSSSH,
'opengear': OpengearSSH,
}

3
netbox/extras/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
netbox/extras/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

2
netbox/ipam/__init__.py Normal file
View File

@ -0,0 +1,2 @@
default_app_config = 'ipam.apps.IPAMConfig'

74
netbox/ipam/admin.py Normal file
View File

@ -0,0 +1,74 @@
from django.contrib import admin
from .models import *
@admin.register(VRF)
class VRFAdmin(admin.ModelAdmin):
list_display = ['name', 'rd']
@admin.register(Status)
class StatusAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'weight', 'bootstrap_class']
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'weight']
@admin.register(RIR)
class RIRAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
@admin.register(Aggregate)
class AggregateAdmin(admin.ModelAdmin):
list_display = ['prefix', 'rir', 'date_added']
list_filter = ['family', 'rir']
search_fields = ['prefix']
@admin.register(Prefix)
class PrefixAdmin(admin.ModelAdmin):
list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan']
list_filter = ['family', 'site', 'status', 'role']
search_fields = ['prefix']
def get_queryset(self, request):
qs = super(PrefixAdmin, self).get_queryset(request)
return qs.select_related('vrf', 'site', 'status', 'role', 'vlan')
@admin.register(IPAddress)
class IPAddressAdmin(admin.ModelAdmin):
list_display = ['address', 'vrf', 'nat_inside']
list_filter = ['family']
fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
readonly_fields = ['interface', 'device', 'nat_inside']
search_fields = ['address']
def get_queryset(self, request):
qs = super(IPAddressAdmin, self).get_queryset(request)
return qs.select_related('vrf', 'nat_inside')
@admin.register(VLAN)
class VLANAdmin(admin.ModelAdmin):
list_display = ['site', 'vid', 'name', 'status', 'role']
list_filter = ['site', 'status', 'role']
search_fields = ['vid', 'name']
def get_queryset(self, request):
qs = super(VLANAdmin, self).get_queryset(request)
return qs.select_related('site', 'status', 'role')

View File

View File

@ -0,0 +1,158 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
#
# VRFs
#
class VRFSerializer(serializers.ModelSerializer):
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'description']
class VRFNestedSerializer(VRFSerializer):
class Meta(VRFSerializer.Meta):
fields = ['id', 'name', 'rd']
#
# Statuses
#
class StatusSerializer(serializers.ModelSerializer):
class Meta:
model = Status
fields = ['id', 'name', 'slug', 'weight', 'bootstrap_class']
class StatusNestedSerializer(StatusSerializer):
class Meta(StatusSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Roles
#
class RoleSerializer(serializers.ModelSerializer):
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight']
class RoleNestedSerializer(RoleSerializer):
class Meta(RoleSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# RIRs
#
class RIRSerializer(serializers.ModelSerializer):
class Meta:
model = RIR
fields = ['id', 'name', 'slug']
class RIRNestedSerializer(RIRSerializer):
class Meta(RIRSerializer.Meta):
pass
#
# Aggregates
#
class AggregateSerializer(serializers.ModelSerializer):
rir = RIRNestedSerializer()
class Meta:
model = Aggregate
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description']
class AggregateNestedSerializer(AggregateSerializer):
class Meta(AggregateSerializer.Meta):
fields = ['id', 'family', 'prefix']
#
# VLANs
#
class VLANSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
site = SiteNestedSerializer()
status = StatusNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = VLAN
fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
def get_display_name(self, obj):
return "{} ({})".format(obj.vid, obj.name)
class VLANNestedSerializer(VLANSerializer):
class Meta(VLANSerializer.Meta):
fields = ['id', 'vid', 'name', 'display_name']
#
# Prefixes
#
class PrefixSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
vrf = VRFNestedSerializer()
vlan = VLANNestedSerializer()
status = StatusNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = Prefix
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description']
class PrefixNestedSerializer(PrefixSerializer):
class Meta(PrefixSerializer.Meta):
fields = ['id', 'family', 'prefix']
#
# IP addresses
#
class IPAddressSerializer(serializers.ModelSerializer):
vrf = VRFNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside']
class IPAddressNestedSerializer(IPAddressSerializer):
class Meta(IPAddressSerializer.Meta):
fields = ['id', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()

40
netbox/ipam/api/urls.py Normal file
View File

@ -0,0 +1,40 @@
from django.conf.urls import url
from .views import *
urlpatterns = [
# VRFs
url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'),
url(r'^vrfs/(?P<pk>\d+)/$', VRFDetailView.as_view(), name='vrf_detail'),
# Statuses
url(r'^statuses/$', StatusListView.as_view(), name='status_list'),
url(r'^statuses/(?P<pk>\d+)/$', StatusDetailView.as_view(), name='status_detail'),
# Roles
url(r'^roles/$', RoleListView.as_view(), name='role_list'),
url(r'^roles/(?P<pk>\d+)/$', RoleDetailView.as_view(), name='role_detail'),
# RIRs
url(r'^rirs/$', RIRListView.as_view(), name='rir_list'),
url(r'^rirs/(?P<pk>\d+)/$', RIRDetailView.as_view(), name='rir_detail'),
# Aggregates
url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'),
url(r'^aggregates/(?P<pk>\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'),
# Prefixes
url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'),
url(r'^prefixes/(?P<pk>\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'),
# IP addresses
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
# VLANs
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
]

140
netbox/ipam/api/views.py Normal file
View File

@ -0,0 +1,140 @@
from rest_framework import generics
from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter
from .serializers import VRFSerializer, StatusSerializer, RoleSerializer, RIRSerializer, AggregateSerializer, \
PrefixSerializer, IPAddressSerializer, VLANSerializer
class VRFListView(generics.ListAPIView):
"""
List all VRFs
"""
queryset = VRF.objects.all()
serializer_class = VRFSerializer
class VRFDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VRF
"""
queryset = VRF.objects.all()
serializer_class = VRFSerializer
class StatusListView(generics.ListAPIView):
"""
List all statuses
"""
queryset = Status.objects.all()
serializer_class = StatusSerializer
class StatusDetailView(generics.RetrieveAPIView):
"""
Retrieve a single status
"""
queryset = Status.objects.all()
serializer_class = StatusSerializer
class RoleListView(generics.ListAPIView):
"""
List all roles
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
class RoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single role
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
class RIRListView(generics.ListAPIView):
"""
List all RIRs
"""
queryset = RIR.objects.all()
serializer_class = RIRSerializer
class RIRDetailView(generics.RetrieveAPIView):
"""
Retrieve a single RIR
"""
queryset = RIR.objects.all()
serializer_class = RIRSerializer
class AggregateListView(generics.ListAPIView):
"""
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = AggregateSerializer
filter_class = AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView):
"""
Retrieve a single aggregate
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = AggregateSerializer
class PrefixListView(generics.ListAPIView):
"""
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role')
serializer_class = PrefixSerializer
filter_class = PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView):
"""
Retrieve a single prefix
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role')
serializer_class = PrefixSerializer
class IPAddressListView(generics.ListAPIView):
"""
List IP addresses (filterable)
"""
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = IPAddressSerializer
filter_class = IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView):
"""
Retrieve a single IP address
"""
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = IPAddressSerializer
class VLANListView(generics.ListAPIView):
"""
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'status', 'role')
serializer_class = VLANSerializer
filter_class = VLANFilter
class VLANDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VLAN
"""
queryset = VLAN.objects.select_related('site', 'status', 'role')
serializer_class = VLANSerializer

6
netbox/ipam/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "ipam"
verbose_name = "IPAM"

82
netbox/ipam/fields.py Normal file
View File

@ -0,0 +1,82 @@
from netaddr import IPNetwork
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.six import with_metaclass
from .formfields import IPFormField
from .lookups import EndsWith, IEndsWith, StartsWith, IStartsWith, Regex, IRegex, NetContained, NetContainedOrEqual, \
NetContains, NetContainsOrEquals, NetHost
class _BaseIPField(models.Field):
def python_type(self):
return IPNetwork
def to_python(self, value):
if not value:
return value
try:
return IPNetwork(value)
except ValueError as e:
raise ValidationError(e)
def get_prep_value(self, value):
if not value:
return None
return str(self.to_python(value))
def form_class(self):
return IPFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
defaults.update(kwargs)
return super(_BaseIPField, self).formfield(**defaults)
class IPNetworkField(with_metaclass(models.SubfieldBase, _BaseIPField)):
"""
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
def db_type(self, connection):
return 'cidr'
IPNetworkField.register_lookup(EndsWith)
IPNetworkField.register_lookup(IEndsWith)
IPNetworkField.register_lookup(StartsWith)
IPNetworkField.register_lookup(IStartsWith)
IPNetworkField.register_lookup(Regex)
IPNetworkField.register_lookup(IRegex)
IPNetworkField.register_lookup(NetContained)
IPNetworkField.register_lookup(NetContainedOrEqual)
IPNetworkField.register_lookup(NetContains)
IPNetworkField.register_lookup(NetContainsOrEquals)
IPNetworkField.register_lookup(NetHost)
class IPAddressField(with_metaclass(models.SubfieldBase, _BaseIPField)):
"""
IP address (host address and mask)
"""
description = "PostgreSQL INET field"
def db_type(self, connection):
return 'inet'
IPAddressField.register_lookup(EndsWith)
IPAddressField.register_lookup(IEndsWith)
IPAddressField.register_lookup(StartsWith)
IPAddressField.register_lookup(IStartsWith)
IPAddressField.register_lookup(Regex)
IPAddressField.register_lookup(IRegex)
IPAddressField.register_lookup(NetContained)
IPAddressField.register_lookup(NetContainedOrEqual)
IPAddressField.register_lookup(NetContains)
IPAddressField.register_lookup(NetContainsOrEquals)
IPAddressField.register_lookup(NetHost)

216
netbox/ipam/filters.py Normal file
View File

@ -0,0 +1,216 @@
import django_filters
from netaddr import IPNetwork
from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from ipam.models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Status, Role
class VRFFilter(django_filters.FilterSet):
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
class Meta:
model = VRF
fields = ['name', 'rd']
class AggregateFilter(django_filters.FilterSet):
rir_id = django_filters.ModelMultipleChoiceFilter(
name='rir',
queryset=RIR.objects.all(),
label='RIR (ID)',
)
rir = django_filters.ModelMultipleChoiceFilter(
name='rir',
queryset=RIR.objects.all(),
to_field_name='slug',
label='RIR (slug)',
)
class Meta:
model = Aggregate
fields = ['family', 'rir_id', 'rir', 'date_added']
class PrefixFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
label='Parent prefix',
)
vrf_id = django_filters.MethodFilter(
action='vrf',
label='VRF (ID)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
name='vlan',
queryset=VLAN.objects.all(),
label='VLAN (ID)',
)
vlan_vid = django_filters.NumberFilter(
name='vlan__vid',
label='VLAN number (1-4095)',
)
status_id = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
label='Status (ID)',
)
status = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
to_field_name='slug',
label='Status (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status_id', 'status', 'role_id',
'role']
def search(self, queryset, value):
value = value.strip()
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contains_or_equals=query)
except AddrFormatError:
return queryset.none()
def search_by_parent(self, queryset, value):
value = value.strip()
if not value:
return queryset
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contained_or_equal=query)
except AddrFormatError:
return queryset.none()
def vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
class IPAddressFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
label='VRF (ID)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
name='interface',
queryset=Interface.objects.all(),
label='Interface (ID)',
)
class Meta:
model = IPAddress
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
def search(self, queryset, value):
value = value.strip()
try:
query = str(IPNetwork(value))
return queryset.filter(address__net_host=query)
except AddrFormatError:
return queryset.none()
class VLANFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
vid = django_filters.NumberFilter(
name='vid',
label='VLAN number (1-4095)',
)
status_id = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
label='Status (ID)',
)
status = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
to_field_name='slug',
label='Status (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
class Meta:
model = VLAN
fields = ['site_id', 'site', 'vid', 'name', 'status_id', 'status', 'role_id', 'role']

View File

@ -0,0 +1,98 @@
- model: ipam.status
pk: 1
fields: {name: Active, slug: active, weight: 1000, bootstrap_class: 1}
- model: ipam.status
pk: 2
fields: {name: Inactive, slug: inactive, weight: 500, bootstrap_class: 3}
- model: ipam.role
pk: 1
fields: {name: Lab Network, slug: lab-network, weight: 1000}
- model: ipam.rir
pk: 1
fields: {name: RFC1918, slug: rfc1918}
- model: ipam.aggregate
pk: 1
fields: {family: 4, prefix: 10.0.0.0/8, rir: 1, date_added: 2016-01-01, description: ''}
- model: ipam.prefix
pk: 1
fields: {family: 4, prefix: 10.1.1.0/24, site: 1, vrf: null, vlan: null, status: 1,
role: 1, description: ''}
- model: ipam.prefix
pk: 2
fields: {family: 4, prefix: 10.0.255.0/24, site: 1, vrf: null, vlan: null, status: 1,
role: 1, description: ''}
- model: ipam.ipaddress
pk: 1
fields: {family: 4, address: 10.0.255.1/32, vrf: null, interface: 3, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 2
fields: {family: 4, address: 169.254.254.1/31, vrf: null, interface: 4, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 3
fields: {family: 4, address: 10.0.255.2/32, vrf: null, interface: 185, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 4
fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 213, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 5
fields: {family: 4, address: 10.0.254.1/24, vrf: null, interface: 12, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 8
fields: {family: 4, address: 10.15.21.1/31, vrf: null, interface: 218, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 9
fields: {family: 4, address: 10.15.21.2/31, vrf: null, interface: 9, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 10
fields: {family: 4, address: 10.15.22.1/31, vrf: null, interface: 8, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 11
fields: {family: 4, address: 10.15.20.1/31, vrf: null, interface: 7, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 12
fields: {family: 4, address: 10.16.20.1/31, vrf: null, interface: 216, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 13
fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 206, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 14
fields: {family: 4, address: 10.16.22.1/31, vrf: null, interface: 217, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 15
fields: {family: 4, address: 10.16.22.2/31, vrf: null, interface: 205, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 16
fields: {family: 4, address: 10.16.20.2/31, vrf: null, interface: 211, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 17
fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 212, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 19
fields: {family: 4, address: 10.0.254.2/32, vrf: null, interface: 188, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 20
fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 200, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 21
fields: {family: 4, address: 169.254.1.2/31, vrf: null, interface: 194, nat_inside: null,
description: ''}
- model: ipam.vlan
pk: 1
fields: {site: 1, vid: 999, name: TEST, status: 1, role: 1}

30
netbox/ipam/formfields.py Normal file
View File

@ -0,0 +1,30 @@
from netaddr import IPNetwork, AddrFormatError
from django import forms
from django.core.exceptions import ValidationError
#
# Form fields
#
class IPFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, IPNetwork):
return value
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
if len(value.split('/')) != 2:
raise ValidationError('CIDR mask (e.g. /24) is required.')
try:
return IPNetwork(value)
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")

408
netbox/ipam/forms.py Normal file
View File

@ -0,0 +1,408 @@
from netaddr import IPNetwork
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface
from utilities.forms import BootstrapMixin, ConfirmationForm, APISelect, Livesearch, CSVDataField, BulkImportForm
from .models import VRF, RIR, Aggregate, Prefix, IPAddress, VLAN, Status, Role
#
# VRFs
#
class VRFForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
help_texts = {
'rd': "Route distinguisher in any format",
}
class VRFFromCSVForm(forms.ModelForm):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
description = forms.CharField(max_length=100, required=False)
class VRFBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
#
# Aggregates
#
class AggregateForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'rir': "Regional Internet Registry responsible for this prefix",
'date_added': "Format: YYYY-MM-DD",
}
class AggregateFromCSVForm(forms.ModelForm):
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'RIR not found.'})
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
class AggregateImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
date_added = forms.DateField(required=False)
description = forms.CharField(max_length=50, required=False)
class AggregateBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
def aggregate_rir_choices():
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
return [(r.slug, '{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
class AggregateFilterForm(forms.Form, BootstrapMixin):
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Prefixes
#
class PrefixForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}'))
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'vrf': "VRF (if applicable)",
'site': "The site to which this prefix is assigned (if applicable)",
'vlan': "The VLAN to which this prefix is assigned (if applicable)",
'status': "Operational status of this prefix",
'role': "The primary function of this prefix",
}
def __init__(self, *args, **kwargs):
super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize field without choices to avoid pulling all VLANs from the database
if self.is_bound and self.data.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].choices = []
def clean_prefix(self):
data = self.cleaned_data['prefix']
try:
prefix = IPNetwork(data)
except:
raise
if prefix.version == 4 and prefix.prefixlen == 32:
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
"addresses instead.")
elif prefix.version == 6 and prefix.prefixlen == 128:
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
"addresses instead.")
return data
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'})
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid status.'})
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'site', 'status', 'role', 'description']
class PrefixImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
class PrefixBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
help_text="Select the VRF to assign, or check below to remove VRF assignment")
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=50, required=False)
class PrefixBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
def prefix_vrf_choices():
vrf_choices = [('', 'All'), (0, 'Global')]
vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
return vrf_choices
def prefix_status_choices():
status_choices = Status.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in status_choices]
def prefix_site_choices():
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
def prefix_role_choices():
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
return [(r.slug, '{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
class PrefixFilterForm(forms.Form, BootstrapMixin):
parent = forms.CharField(required=False, label='Search Within')
vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF')
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices)
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
#
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
)
nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)',
widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'))
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description']
help_texts = {
'address': "IPv4 or IPv6 address and mask",
'vrf': "VRF (if applicable)",
}
def __init__(self, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(rack__site=nat_inside.interface.device.rack.site)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device=nat_inside.interface.device)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
else:
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
# Initialize nat_inside choices if nat_device is set
if self.is_bound and self.data.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.data['nat_device'])
elif self.initial.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.initial['nat_device'])
else:
self.fields['nat_inside'].choices = []
class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'Site not found.'})
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
is_primary = forms.BooleanField(required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description']
def clean(self):
device = self.cleaned_data.get('device')
interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary')
# Validate interface
if device and interface_name:
try:
Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist:
self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device))
elif device and not interface_name:
self.add_error('interface_name', "Device set ({}) but interface missing".format(device))
elif interface_name and not device:
self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name))
# Validate is_primary
if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP")
def save(self, commit=True):
# Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['interface_name'])
# Set as primary for device
if self.cleaned_data['is_primary']:
self.instance.primary_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(commit=commit)
class IPAddressImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
help_text="Select the VRF to assign, or check below to remove VRF assignment")
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
description = forms.CharField(max_length=50, required=False)
class IPAddressBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
def ipaddress_family_choices():
return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
class IPAddressFilterForm(forms.Form, BootstrapMixin):
family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
#
# VLANs
#
class VLANForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
help_texts = {
'site': "The site at which this VLAN exists",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid status.'})
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
class VLANImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VLANFromCSVForm)
class VLANBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
class VLANBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
def vlan_site_choices():
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_status_choices():
status_choices = Status.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in status_choices]
def vlan_role_choices():
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
return [(r.slug, '{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
class VLANFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

89
netbox/ipam/lookups.py Normal file
View File

@ -0,0 +1,89 @@
from django.db.models import Lookup
from django.db.models.lookups import BuiltinLookup
class NetFieldDecoratorMixin(object):
def process_lhs(self, qn, connection, lhs=None):
lhs = lhs or self.lhs
lhs_string, lhs_params = qn.compile(lhs)
lhs_string = 'TEXT(%s)' % lhs_string
return lhs_string, lhs_params
class EndsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'endswith'
class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'iendswith'
class StartsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'startswith'
class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'istartswith'
class Regex(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'regex'
class IRegex(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'iregex'
class NetContainsOrEquals(Lookup):
lookup_name = 'net_contains_or_equals'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s >>= %s' % (lhs, rhs), params
class NetContains(Lookup):
lookup_name = 'net_contains'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s >> %s' % (lhs, rhs), params
class NetContained(Lookup):
lookup_name = 'net_contained'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s << %s' % (lhs, rhs), params
class NetContainedOrEqual(Lookup):
lookup_name = 'net_contained_or_equal'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s <<= %s' % (lhs, rhs), params
class NetHost(Lookup):
lookup_name = 'net_host'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
# Query parameters are automatically converted to IPNetwork objects, which are then turned to strings. We need
# to omit the mask portion of the object's string representation to match PostgreSQL's HOST() function.
if rhs_params:
rhs_params[0] = rhs_params[0].split('/')[0]
params = lhs_params + rhs_params
return 'HOST(%s) = %s' % (lhs, rhs), params

View File

@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import ipam.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Aggregate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')])),
('prefix', ipam.fields.IPNetworkField()),
('date_added', models.DateField(blank=True, null=True)),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['family', 'prefix'],
},
),
migrations.CreateModel(
name='IPAddress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)),
('address', ipam.fields.IPAddressField()),
('description', models.CharField(blank=True, max_length=100)),
('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ip_addresses', to='dcim.Interface')),
('nat_inside', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT IP (inside)')),
],
options={
'ordering': ['family', 'address'],
'verbose_name': 'IP address',
'verbose_name_plural': 'IP addresses',
},
),
migrations.CreateModel(
name='Prefix',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)),
('prefix', ipam.fields.IPNetworkField()),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['family', 'prefix'],
'verbose_name_plural': 'prefixes',
},
),
migrations.CreateModel(
name='RIR',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
'verbose_name': 'RIR',
'verbose_name_plural': 'RIRs',
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('weight', models.PositiveSmallIntegerField(default=1000)),
],
options={
'ordering': ['weight', 'name'],
},
),
migrations.CreateModel(
name='Status',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('weight', models.PositiveSmallIntegerField(default=1000)),
('bootstrap_class', models.PositiveSmallIntegerField(choices=[(0, b'Default'), (1, b'Primary'), (2, b'Success'), (3, b'Info'), (4, b'Warning'), (5, b'Danger')], default=0)),
],
options={
'ordering': ['weight', 'name'],
'verbose_name_plural': 'statuses',
},
),
migrations.CreateModel(
name='VLAN',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name=b'ID')),
('name', models.CharField(max_length=30)),
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vlans', to='ipam.Role')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site')),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.Status')),
],
options={
'ordering': ['site', 'vid'],
'verbose_name': 'VLAN',
'verbose_name_plural': 'VLANs',
},
),
migrations.CreateModel(
name='VRF',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('rd', models.CharField(max_length=21, unique=True, verbose_name=b'Route distinguisher')),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['name'],
'verbose_name': 'VRF',
'verbose_name_plural': 'VRFs',
},
),
migrations.AddField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AddField(
model_name='prefix',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.Site'),
),
migrations.AddField(
model_name='prefix',
name='status',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.Status'),
),
migrations.AddField(
model_name='prefix',
name='vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name=b'VLAN'),
),
migrations.AddField(
model_name='prefix',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name=b'VRF'),
),
migrations.AddField(
model_name='ipaddress',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name=b'VRF'),
),
migrations.AddField(
model_name='aggregate',
name='rir',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name=b'RIR'),
),
]

View File

275
netbox/ipam/models.py Normal file
View File

@ -0,0 +1,275 @@
from netaddr import cidr_merge
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from dcim.models import Interface
from .fields import IPNetworkField, IPAddressField
AF_CHOICES = (
(4, 'IPv4'),
(6, 'IPv6'),
)
BOOTSTRAP_CLASS_CHOICES = (
(0, 'Default'),
(1, 'Primary'),
(2, 'Success'),
(3, 'Info'),
(4, 'Warning'),
(5, 'Danger'),
)
class VRF(models.Model):
"""
A discrete layer three forwarding domain (e.g. a routing table)
"""
name = models.CharField(max_length=50)
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['name']
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('ipam:vrf', args=[self.pk])
class Status(models.Model):
"""
The status of a prefix or VLAN (e.g. allocated, reserved, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000)
bootstrap_class = models.PositiveSmallIntegerField(choices=BOOTSTRAP_CLASS_CHOICES, default=0)
class Meta:
ordering = ['weight', 'name']
verbose_name_plural = 'statuses'
def __unicode__(self):
return self.name
class Role(models.Model):
"""
The role of an address resource (e.g. customer, infrastructure, mgmt, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000)
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
return self.name
class RIR(models.Model):
"""
A regional Internet registry (e.g. ARIN) or governing standard (e.g. RFC 1918)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
return self.name
class Aggregate(models.Model):
"""
A top-level IPv4 or IPv6 prefix
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES)
prefix = IPNetworkField()
rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
date_added = models.DateField(blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:aggregate', args=[self.pk])
def clean(self):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# Ensure that the aggregate being added is not covered by an existing aggregate
covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
if self.pk:
covering_aggregates = covering_aggregates.exclude(pk=self.pk)
if covering_aggregates:
raise ValidationError("{} is already covered by an existing aggregate ({})"
.format(self.prefix, covering_aggregates[0]))
def save(self, *args, **kwargs):
if self.prefix:
# Infer address family from IPNetwork object
self.family = self.prefix.version
super(Aggregate, self).save(*args, **kwargs)
def get_utilization(self):
"""
Determine the utilization rate of the aggregate prefix and return it as a percentage.
"""
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
# Remove overlapping prefixes from list of children
networks = cidr_merge([c.prefix for c in child_prefixes])
children_size = float(0)
for p in networks:
children_size += p.size
return int(children_size / self.prefix.size * 100)
class PrefixQuerySet(models.QuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return filter(lambda p: p.depth <= limit, queryset)
class Prefix(models.Model):
"""
An IPv4 or IPv6 prefix, including mask length
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
prefix = IPNetworkField()
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF')
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VLAN')
status = models.ForeignKey('Status', related_name='prefixes', on_delete=models.PROTECT)
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
objects = PrefixQuerySet.as_manager()
class Meta:
ordering = ['family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# Infer address family from IPNetwork object
self.family = self.prefix.version
super(Prefix, self).save(*args, **kwargs)
class IPAddress(models.Model):
"""
An IPv4 or IPv6 address
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
address = IPAddressField()
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF')
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True)
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT IP (inside)')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
return str(self.address)
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def save(self, *args, **kwargs):
if self.address:
# Infer address family from IPAddress object
self.family = self.address.version
super(IPAddress, self).save(*args, **kwargs)
@property
def device(self):
if self.interface:
return self.interface.device
return None
class VLAN(models.Model):
"""
A VLAN within a site
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
MaxValueValidator(4094)
])
name = models.CharField(max_length=30)
status = models.ForeignKey('Status', related_name='vlans', on_delete=models.PROTECT)
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
class Meta:
ordering = ['site', 'vid']
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
return "{0} ({1})".format(self.vid, self.name)
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])

210
netbox/ipam/tables.py Normal file
View File

@ -0,0 +1,210 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Aggregate, Prefix, IPAddress, VLAN, VRF
UTILIZATION_GRAPH = """
{% with record.get_utilization as percentage %}
<div class="progress text-center">
{% if percentage < 15 %}<span style="font-size: 12px;">{{ percentage }}%</span>{% endif %}
<div class="progress-bar progress-bar-{% if percentage >= 90 %}danger{% elif percentage >= 75 %}warning{% else %}success{% endif %}" role="progressbar" aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage }}%">
{% if percentage >= 15 %}{{ percentage }}%{% endif %}
</div>
</div>
{% endwith %}
"""
PREFIX_LINK = """
{% if record.has_children %}
<span style="padding-left: {{ record.depth }}0px "><i class="fa fa-caret-right"></i></a>
{% else %}
<span style="padding-left: {{ record.depth }}9px">
{% endif %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% endif %}">{{ record.prefix }}</a>
</span>
"""
PREFIX_LINK_BRIEF = """
<span style="padding-left: {{ record.depth }}0px">
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% endif %}">{{ record.prefix }}</a>
</span>
"""
STATUS_LABEL = """
{% if record.pk %}
<span class="label label-{{ record.status.get_bootstrap_class_display|lower }}">{{ record.status.name }}</span>
{% else %}
<span class="label label-success">Available</span>
{% endif %}
"""
#
# VRFs
#
class VRFTable(tables.Table):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = VRF
fields = ('name', 'rd', 'description')
empty_text = "No VRFs found."
attrs = {
'class': 'table table-hover',
}
class VRFBulkEditTable(VRFTable):
pk = tables.CheckBoxColumn()
class Meta(VRFTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'rd', 'description')
#
# Aggregates
#
class AggregateTable(tables.Table):
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
rir = tables.Column(verbose_name='RIR')
child_count = tables.Column(verbose_name='Prefixes')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = Aggregate
fields = ('prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
empty_text = "No aggregates found."
attrs = {
'class': 'table table-hover',
}
class AggregateBulkEditTable(AggregateTable):
pk = tables.CheckBoxColumn()
class Meta(AggregateTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
#
# Prefixes
#
class PrefixTable(tables.Table):
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
role = tables.Column(verbose_name='Role')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = Prefix
fields = ('prefix', 'status', 'vrf', 'site', 'role', 'description')
empty_text = "No prefixes found."
attrs = {
'class': 'table table-hover',
}
class PrefixBriefTable(tables.Table):
prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta:
model = Prefix
fields = ('prefix', 'status', 'site', 'role')
empty_text = "No prefixes found."
attrs = {
'class': 'table table-hover',
}
class PrefixBulkEditTable(PrefixTable):
pk = tables.CheckBoxColumn(default='')
class Meta(PrefixTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description')
#
# IPAddresses
#
class IPAddressTable(tables.Table):
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
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(sortable=False, verbose_name='Description')
class Meta:
model = IPAddress
fields = ('address', 'vrf', 'device', 'interface', 'description')
empty_text = "No IP addresses found."
attrs = {
'class': 'table table-hover',
}
class IPAddressBriefTable(tables.Table):
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)')
class Meta:
model = IPAddress
fields = ('address', 'device', 'interface', 'nat_inside')
empty_text = "No IP addresses found."
attrs = {
'class': 'table table-hover',
}
class IPAddressBulkEditTable(IPAddressTable):
pk = tables.CheckBoxColumn()
class Meta(IPAddressTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description')
#
# VLANs
#
class VLANTable(tables.Table):
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
name = tables.Column(verbose_name='Name')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta:
model = VLAN
fields = ('vid', 'site', 'name', 'status', 'role')
empty_text = "No VLANs found."
attrs = {
'class': 'table table-hover',
}
class VLANBulkEditTable(VLANTable):
pk = tables.CheckBoxColumn()
class Meta(VLANTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'vid', 'site', 'name', 'status', 'role')

3
netbox/ipam/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

51
netbox/ipam/urls.py Normal file
View File

@ -0,0 +1,51 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^vrfs/$', views.vrf_list, name='vrf_list'),
url(r'^vrfs/add/$', views.vrf_add, name='vrf_add'),
url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.vrf_edit, name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.vrf_delete, name='vrf_delete'),
url(r'^aggregates/$', views.aggregate_list, name='aggregate_list'),
url(r'^aggregates/add/$', views.aggregate_add, name='aggregate_add'),
url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.aggregate_edit, name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.aggregate_delete, name='aggregate_delete'),
url(r'^prefixes/$', views.prefix_list, name='prefix_list'),
url(r'^prefixes/add/$', views.prefix_add, name='prefix_add'),
url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.prefix_edit, name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.prefix_delete, name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'),
url(r'^ip-addresses/$', views.ipaddress_list, name='ipaddress_list'),
url(r'^ip-addresses/add/$', views.ipaddress_add, name='ipaddress_add'),
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.ipaddress_edit, name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.ipaddress_delete, name='ipaddress_delete'),
url(r'^vlans/$', views.vlan_list, name='vlan_list'),
url(r'^vlans/add/$', views.vlan_add, name='vlan_add'),
url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.vlan_edit, name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.vlan_delete, name='vlan_delete'),
]

899
netbox/ipam/views.py Normal file
View File

@ -0,0 +1,899 @@
from netaddr import IPNetwork, IPSet
from netaddr.core import AddrFormatError
from django_tables2 import RequestConfig
from django.conf import settings
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.models import ProtectedError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import urlencode
from dcim.models import Device
from extras.models import ExportTemplate
from utilities.error_handlers import handle_protectederror
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkImportView, BulkEditView, BulkDeleteView
from .filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
from .forms import AggregateForm, AggregateImportForm, AggregateBulkEditForm, AggregateBulkDeleteForm, \
AggregateFilterForm, PrefixForm, PrefixImportForm, PrefixBulkEditForm, PrefixBulkDeleteForm, PrefixFilterForm, \
IPAddressForm, IPAddressImportForm, IPAddressBulkEditForm, IPAddressBulkDeleteForm, IPAddressFilterForm, VLANForm, \
VLANImportForm, VLANBulkEditForm, VLANBulkDeleteForm, VRFForm, VRFImportForm, VRFBulkEditForm, VRFBulkDeleteForm, \
VLANFilterForm
from .models import VRF, Aggregate, Prefix, VLAN
from .tables import AggregateTable, AggregateBulkEditTable, PrefixTable, PrefixBriefTable, PrefixBulkEditTable, \
IPAddress, IPAddressBriefTable, IPAddressTable, IPAddressBulkEditTable, VLANTable, VLANBulkEditTable, VRFTable, \
VRFBulkEditTable
def add_available_prefixes(parent, prefix_list):
"""
Create fake Prefix objects for all unallocated space within a prefix.
"""
# Find all unallocated space
available_prefixes = IPSet(parent) ^ IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()]
# Concatenate and sort complete list of children
prefix_list = list(prefix_list) + available_prefixes
prefix_list.sort(key=lambda p: p.prefix)
return prefix_list
#
# VRFs
#
def vrf_list(request):
queryset = VRF.objects.all()
queryset = VRFFilter(request.GET, queryset).qs
# annotate_depth(queryset)
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='vrf', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vrfs')
return response
if request.user.has_perm('ipam.change_vrf') or request.user.has_perm('ipam.delete_vrf'):
vrf_table = VRFBulkEditTable(queryset)
else:
vrf_table = VRFTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vrf_table)
export_templates = ExportTemplate.objects.filter(content_type__model='vrf')
return render(request, 'ipam/vrf_list.html', {
'vrf_table': vrf_table,
'export_templates': export_templates,
})
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
'prefixes': prefixes,
})
@permission_required('ipam.add_vrf')
def vrf_add(request):
if request.method == 'POST':
form = VRFForm(request.POST)
if form.is_valid():
vrf = form.save()
messages.success(request, "Added new VRF: {0}".format(vrf))
if '_addanother' in request.POST:
return redirect('ipam:vrf_add')
else:
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = VRFForm()
return render(request, 'ipam/vrf_edit.html', {
'form': form,
'cancel_url': reverse('ipam:vrf_list'),
})
@permission_required('ipam.change_vrf')
def vrf_edit(request, pk):
vrf = get_object_or_404(VRF, pk=pk)
if request.method == 'POST':
form = VRFForm(request.POST, instance=vrf)
if form.is_valid():
vrf = form.save()
messages.success(request, "Modified VRF {0}".format(vrf))
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = VRFForm(instance=vrf)
return render(request, 'ipam/vrf_edit.html', {
'vrf': vrf,
'form': form,
'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk}),
})
@permission_required('ipam.delete_vrf')
def vrf_delete(request, pk):
vrf = get_object_or_404(VRF, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
vrf.delete()
messages.success(request, "VRF {0} has been deleted".format(vrf))
return redirect('ipam:vrf_list')
except ProtectedError, e:
handle_protectederror(vrf, request, e)
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/vrf_delete.html', {
'vrf': vrf,
'form': form,
'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk})
})
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vrf'
form = VRFImportForm
table = VRFTable
template_name = 'ipam/vrf_import.html'
obj_list_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
form = VRFBulkEditForm
template_name = 'ipam/vrf_bulk_edit.html'
redirect_url = 'ipam:vrf_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} VRFs".format(updated_count))
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
form = VRFBulkDeleteForm
template_name = 'ipam/vrf_bulk_delete.html'
redirect_url = 'ipam:vrf_list'
#
# Aggregates
#
def aggregate_list(request):
queryset = Aggregate.objects.select_related('rir').extra(
select = {
'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
}
)
queryset = AggregateFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='aggregate', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_aggregates')
return response
if request.user.has_perm('ipam.change_aggregate') or request.user.has_perm('ipam.delete_aggregate'):
aggregate_table = AggregateBulkEditTable(queryset)
else:
aggregate_table = AggregateTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(aggregate_table)
export_templates = ExportTemplate.objects.filter(content_type__model='aggregate')
return render(request, 'ipam/aggregate_list.html', {
'aggregate_table': aggregate_table,
'export_templates': export_templates,
'filter_form': AggregateFilterForm(request.GET, label_suffix=''),
})
def aggregate(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\
.select_related('site', 'status', 'role').annotate_depth(limit=0)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table = PrefixBulkEditTable(child_prefixes)
else:
prefix_table = PrefixTable(child_prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(prefix_table)
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
})
@permission_required('ipam.add_aggregate')
def aggregate_add(request):
if request.method == 'POST':
form = AggregateForm(request.POST)
if form.is_valid():
aggregate = form.save()
messages.success(request, "Added new aggregate: {0}".format(aggregate.prefix))
if '_addanother' in request.POST:
return redirect('ipam:aggregate_add')
else:
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = AggregateForm()
return render(request, 'ipam/aggregate_edit.html', {
'form': form,
'cancel_url': reverse('ipam:aggregate_list'),
})
@permission_required('ipam.change_aggregate')
def aggregate_edit(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
if request.method == 'POST':
form = AggregateForm(request.POST, instance=aggregate)
if form.is_valid():
aggregate = form.save()
messages.success(request, "Modified aggregate {0}".format(aggregate.prefix))
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = AggregateForm(instance=aggregate)
return render(request, 'ipam/aggregate_edit.html', {
'aggregate': aggregate,
'form': form,
'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk}),
})
@permission_required('ipam.delete_aggregate')
def aggregate_delete(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
aggregate.delete()
messages.success(request, "Aggregate {0} has been deleted".format(aggregate))
return redirect('ipam:aggregate_list')
except ProtectedError, e:
handle_protectederror(aggregate, request, e)
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/aggregate_delete.html', {
'aggregate': aggregate,
'form': form,
'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk})
})
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_aggregate'
form = AggregateImportForm
table = AggregateTable
template_name = 'ipam/aggregate_import.html'
obj_list_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
form = AggregateBulkEditForm
template_name = 'ipam/aggregate_bulk_edit.html'
redirect_url = 'ipam:aggregate_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['rir', 'date_added', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} aggregates".format(updated_count))
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
form = AggregateBulkDeleteForm
template_name = 'ipam/aggregate_bulk_delete.html'
redirect_url = 'ipam:aggregate_list'
#
# Prefixes
#
def prefix_list(request):
queryset = Prefix.objects.select_related('site', 'status', 'role')
queryset = PrefixFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='prefix', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_prefixes')
return response
# Show only top-level prefixes by default
limit = None if request.GET.get('expand') else 0
prefixes = queryset.annotate_depth(limit=limit)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table = PrefixBulkEditTable(prefixes)
else:
prefix_table = PrefixTable(prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(prefix_table)
export_templates = ExportTemplate.objects.filter(content_type__model='prefix')
return render(request, 'ipam/prefix_list.html', {
'prefix_table': prefix_table,
'export_templates': export_templates,
'filter_form': PrefixFilterForm(request.GET, label_suffix=''),
})
def prefix(request, pk):
prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'status', 'role'), pk=pk)
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
except Aggregate.DoesNotExist:
aggregate = None
# Count child IP addresses
ipaddress_count = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix)).count()
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'status', 'role').annotate_depth()
parent_prefix_table = PrefixBriefTable(parent_prefixes)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'status', 'role')
duplicate_prefix_table = PrefixBriefTable(duplicate_prefixes)
# Child prefixes table
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
.select_related('site', 'status', 'role').annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table = PrefixBulkEditTable(child_prefixes)
else:
child_prefix_table = PrefixTable(child_prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(child_prefix_table)
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
'aggregate': aggregate,
'ipaddress_count': ipaddress_count,
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
})
@permission_required('ipam.add_prefix')
def prefix_add(request):
if request.method == 'POST':
form = PrefixForm(request.POST)
if form.is_valid():
prefix = form.save()
messages.success(request, "Added new prefix: {0}".format(prefix.prefix))
if '_addanother' in request.POST:
return redirect('ipam:prefix_add')
else:
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = PrefixForm(initial={
'site': request.GET.get('site'),
'prefix': request.GET.get('prefix'),
})
return render(request, 'ipam/prefix_edit.html', {
'form': form,
'cancel_url': reverse('ipam:prefix_list'),
})
@permission_required('ipam.change_prefix')
def prefix_edit(request, pk):
prefix = get_object_or_404(Prefix, pk=pk)
if request.method == 'POST':
form = PrefixForm(request.POST, instance=prefix)
if form.is_valid():
prefix = form.save()
messages.success(request, "Modified prefix {0}".format(prefix.prefix))
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = PrefixForm(instance=prefix)
return render(request, 'ipam/prefix_edit.html', {
'prefix': prefix,
'form': form,
'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk}),
})
@permission_required('ipam.delete_prefix')
def prefix_delete(request, pk):
prefix = get_object_or_404(Prefix, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
prefix.delete()
messages.success(request, "Prefix {0} has been deleted".format(prefix))
return redirect('ipam:prefix_list')
except ProtectedError, e:
handle_protectederror(prefix, request, e)
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/prefix_delete.html', {
'prefix': prefix,
'form': form,
'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk})
})
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
form = PrefixImportForm
table = PrefixTable
template_name = 'ipam/prefix_import.html'
obj_list_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
form = PrefixBulkEditForm
template_name = 'ipam/prefix_bulk_edit.html'
redirect_url = 'ipam:prefix_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['vrf']:
fields_to_update['vrf'] = form.cleaned_data['vrf']
elif form.cleaned_data['vrf_global']:
fields_to_update['vrf'] = None
for field in ['site', 'status', 'role', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} prefixes".format(updated_count))
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
form = PrefixBulkDeleteForm
template_name = 'ipam/prefix_bulk_delete.html'
redirect_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
.select_related('vrf', 'interface__device', 'primary_for')
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table = IPAddressBulkEditTable(ipaddresses)
else:
ip_table = IPAddressTable(ipaddresses)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(ip_table)
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
})
#
# IP addresses
#
def ipaddress_list(request):
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_for')
queryset = IPAddressFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='ipaddress', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_ips')
return response
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table = IPAddressBulkEditTable(queryset)
else:
ip_table = IPAddressTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(ip_table)
export_templates = ExportTemplate.objects.filter(content_type__model='ipaddress')
# If searching and no IPAddresses were found, include a list of parent prefixes matching the query
prefix_table = None
if request.GET.get('q') and not queryset:
try:
ip = str(IPNetwork(request.GET.get('q')))
prefix_table = PrefixTable(Prefix.objects.filter(prefix__net_contains_or_equals=ip))
RequestConfig(request).configure(prefix_table)
except AddrFormatError:
pass
return render(request, 'ipam/ipaddress_list.html', {
'ip_table': ip_table,
'prefix_table': prefix_table,
'export_templates': export_templates,
'filter_form': IPAddressFilterForm(request.GET, label_suffix=''),
})
def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
related_ips = IPAddress.objects.select_related('interface__device').exclude(pk=ipaddress.pk).filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = IPAddressBriefTable(related_ips)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(related_ips_table)
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
'parent_prefixes': parent_prefixes,
'related_ips_table': related_ips_table,
})
@permission_required('ipam.add_ipaddress')
def ipaddress_add(request):
if request.method == 'POST':
form = IPAddressForm(request.POST)
if form.is_valid():
ipaddress = form.save()
messages.success(request, "Created new IP Address: {0}".format(ipaddress))
if '_addanother' in request.POST:
return redirect('ipam:ipaddress_add')
else:
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = IPAddressForm(initial={
'ipaddress': request.GET.get('ipaddress', None),
})
return render(request, 'ipam/ipaddress_edit.html', {
'form': form,
'cancel_url': reverse('ipam:ipaddress_list'),
})
@permission_required('ipam.change_ipaddress')
def ipaddress_edit(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = IPAddressForm(request.POST, instance=ipaddress)
if form.is_valid():
ipaddress = form.save()
messages.success(request, "Modified IP address {0}".format(ipaddress))
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = IPAddressForm(instance=ipaddress)
return render(request, 'ipam/ipaddress_edit.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@permission_required('ipam.delete_ipaddress')
def ipaddress_delete(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
ipaddress.delete()
messages.success(request, "IP address {0} has been deleted".format(ipaddress))
if ipaddress.interface:
return redirect('dcim:device', pk=ipaddress.interface.device.pk)
else:
return redirect('ipam:ipaddress_list')
except ProtectedError, e:
handle_protectederror(ipaddress, request, e)
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = ConfirmationForm()
# Upon cancellation, redirect to the assigned device if one exists
if ipaddress.interface:
cancel_url = reverse('dcim:device', kwargs={'pk': ipaddress.interface.device.pk})
else:
cancel_url = reverse('ipam:ipaddress_list')
return render(request, 'ipam/ipaddress_delete.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': cancel_url,
})
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress'
form = IPAddressImportForm
table = IPAddressTable
template_name = 'ipam/ipaddress_import.html'
obj_list_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
obj.save()
# Update primary IP for device if needed
try:
device = obj.primary_for
device.primary_ip = obj
device.save()
except Device.DoesNotExist:
pass
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
form = IPAddressBulkEditForm
template_name = 'ipam/ipaddress_bulk_edit.html'
redirect_url = 'ipam:ipaddress_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['vrf']:
fields_to_update['vrf'] = form.cleaned_data['vrf']
elif form.cleaned_data['vrf_global']:
fields_to_update['vrf'] = None
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} IP addresses".format(updated_count))
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
form = IPAddressBulkDeleteForm
template_name = 'ipam/ipaddress_bulk_delete.html'
redirect_url = 'ipam:ipaddress_list'
#
# VLANs
#
def vlan_list(request):
queryset = VLAN.objects.select_related('site', 'status', 'role')
queryset = VLANFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='vlan', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vlans')
return response
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlan_table = VLANBulkEditTable(queryset)
else:
vlan_table = VLANTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vlan_table)
export_templates = ExportTemplate.objects.filter(content_type__model='vlan')
return render(request, 'ipam/vlan_list.html', {
'vlan_table': vlan_table,
'export_templates': export_templates,
'filter_form': VLANFilterForm(request.GET, label_suffix=''),
})
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'status', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
'prefixes': prefixes,
})
@permission_required('ipam.add_vlan')
def vlan_add(request):
if request.method == 'POST':
form = VLANForm(request.POST)
if form.is_valid():
vlan = form.save()
messages.success(request, "Added new VLAN: {0}".format(vlan))
if '_addanother' in request.POST:
base_url = reverse('ipam:vlan_add')
params = urlencode({
'site': vlan.site.pk,
})
return HttpResponseRedirect('{}?{}'.format(base_url, params))
else:
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = VLANForm()
return render(request, 'ipam/vlan_edit.html', {
'form': form,
'cancel_url': reverse('ipam:vlan_list'),
})
@permission_required('ipam.change_vlan')
def vlan_edit(request, pk):
vlan = get_object_or_404(VLAN, pk=pk)
if request.method == 'POST':
form = VLANForm(request.POST, instance=vlan)
if form.is_valid():
vlan = form.save()
messages.success(request, "Modified VLAN {0}".format(vlan))
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = VLANForm(instance=vlan)
return render(request, 'ipam/vlan_edit.html', {
'vlan': vlan,
'form': form,
'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk}),
})
@permission_required('ipam.delete_vlan')
def vlan_delete(request, pk):
vlan = get_object_or_404(VLAN, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
vlan.delete()
messages.success(request, "VLAN {0} has been deleted".format(vlan))
return redirect('ipam:vlan_list')
except ProtectedError, e:
handle_protectederror(vlan, request, e)
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/vlan_delete.html', {
'vlan': vlan,
'form': form,
'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk})
})
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlan'
form = VLANImportForm
table = VLANTable
template_name = 'ipam/vlan_import.html'
obj_list_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
form = VLANBulkEditForm
template_name = 'ipam/vlan_bulk_edit.html'
redirect_url = 'ipam:vlan_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['site', 'status', 'role']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} VLANs".format(updated_count))
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
form = VLANBulkDeleteForm
template_name = 'ipam/vlan_bulk_delete.html'
redirect_url = 'ipam:vlan_list'

10
netbox/manage.py Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

View File

@ -0,0 +1,36 @@
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = ''
# If enabled, NetBox will run with debugging turned on. This should only be used for development or troubleshooting.
# NEVER ENABLE DEBUGGING ON A PRODUCTION SYSTEM.
DEBUG = False
# Set this to your server's FQDN. This is required when DEBUG = False.
# E.g. ALLOWED_HOSTS = ['netbox.yourdomain.com']
ALLOWED_HOSTS = []
# Setting this to true will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False
# PostgreSQL database configuration.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': '', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
}
# If true, user authentication will be required for all site access. If false, unauthenticated users will be able to
# access NetBox but not make any changes.
LOGIN_REQUIRED = False
# Credentials that NetBox will use to access live devices. (Optional)
NETBOX_USERNAME = ''
NETBOX_PASSWORD = ''

140
netbox/netbox/settings.py Normal file
View File

@ -0,0 +1,140 @@
"""
Django settings for netbox project.
Generated by 'django-admin startproject' using Django 1.8.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.8/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
import socket
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Application definition
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'debug_toolbar',
'django_extensions',
'django_tables2',
'rest_framework',
'rest_framework_swagger',
'circuits',
'dcim',
'ipam',
'extras',
'secrets',
'users',
'utilities',
)
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'utilities.middleware.LoginRequiredMiddleware',
)
ROOT_URLCONF = 'netbox.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR + '/templates/'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings',
'django.core.context_processors.request',
],
},
},
]
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
)
# Messages
from django.contrib.messages import constants as messages
MESSAGE_TAGS = {
messages.ERROR: 'danger',
}
# Pagination
PAGINATE_COUNT = 50
# Authentication
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_URL = '/logout/'
# Default time formats
DATE_FORMAT = 'N j, Y'
SHORT_DATE_FORMAT = 'Y-m-d'
TIME_FORMAT = 'g:i:s a'
SHORT_TIME_FORMAT = 'H:i:s'
DATETIME_FORMAT = 'N j, Y \a\t g:i a'
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048
# Django REST framework
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
}
try:
HOSTNAME = socket.gethostname()
except:
HOSTNAME = 'localhost'
# Import local configuration
try:
from configuration import *
except ImportError:
pass
# django-cors-headers (API Cross-Origin Resource Sharing)
if DEBUG:
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_METHODS = (
'GET',
'OPTIONS',
)

32
netbox/netbox/urls.py Normal file
View File

@ -0,0 +1,32 @@
from django.conf.urls import include, url
from django.contrib import admin
from django.views.defaults import page_not_found
from views import home, trigger_500
from users.views import login, logout
urlpatterns = [
url(r'^$', home, name='home'),
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^profile/', include('users.urls', namespace='users')),
url(r'^login/$', login, name='login'),
url(r'^logout/$', logout, name='logout'),
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Error testing
url(r'^404/$', page_not_found),
url(r'^500/$', trigger_500),
url(r'^admin/', include(admin.site.urls)),
]

45
netbox/netbox/views.py Normal file
View File

@ -0,0 +1,45 @@
from django.shortcuts import render
from circuits.models import Provider, Circuit
from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
from ipam.models import Aggregate, Prefix, IPAddress, VLAN
from secrets.models import Secret
def home(request):
stats = {
# DCIM
'site_count': Site.objects.count(),
'rack_count': Rack.objects.count(),
'device_count': Device.objects.count(),
'interface_connections_count': InterfaceConnection.objects.count(),
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
# IPAM
'aggregate_count': Aggregate.objects.count(),
'prefix_count': Prefix.objects.count(),
'ipaddress_count': IPAddress.objects.count(),
'vlan_count': VLAN.objects.count(),
# Circuits
'provider_count': Provider.objects.count(),
'circuit_count': Circuit.objects.count(),
# Secrets
'secret_count': Secret.objects.count(),
}
return render(request, 'home.html', {
'stats': stats,
})
def trigger_500(request):
"""Hot-wired method of triggering a server error to test reporting."""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")

16
netbox/netbox/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for do_ipam project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
application = get_wsgi_application()

View File

@ -0,0 +1,224 @@
/* Layout */
* {
margin: 0;
}
html, body {
height: 100%;
}
body {
padding-top: 70px;
}
.container {
width: 1340px;
}
.wrapper {
min-height: 100%;
height: auto !important;
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
padding-bottom: 30px;
}
.footer, .push {
height: 60px; /* .push must be the same height as .footer */
}
.footer {
background-color: #f5f5f5;
border-top: 1px solid #d0d0d0;
}
footer p {
margin: 20px 0;
}
/* Forms */
label {
font-weight: normal;
}
label.required {
font-weight: bold;
}
/* Tables */
th.pk, td.pk {
width: 30px;
}
/* Paginator */
nav ul.pagination {
margin-top: 0;
}
/* Racks */
div.rack_header {
margin-left: 36px;
text-align: center;
width: 200px;
}
ul.rack_legend {
float: left;
list-style-type: none;
margin-right: 6px;
padding: 0;
width: 30px;
}
ul.rack_legend li {
color: #c0c0c0;
display: block;
font-size: 10px;
height: 20px;
overflow: hidden;
padding: 5px 0;
text-align: right;
}
div.rack_frame {
float: left;
position: relative;
}
ul.rack {
border: 2px solid #404040;
float: left;
list-style-type: none;
padding: 0;
position: absolute;
width: 200px;
}
ul.rack li {
display: block;
font-size: 13px;
height: 20px;
overflow: hidden;
text-align: center;
}
ul.rack_empty li {
background-color: #f7f7f7;
border-bottom: 1px solid #dddddd;
height: 20px;
}
ul.rack li.empty:last-child {
border-bottom: 0;
}
ul.rack_far_face {
z-index: 100;
}
ul.rack_near_face {
z-index: 200;
}
ul.rack li.h2u { height: 40px; }
ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; }
ul.rack li.h3u { height: 60px; }
ul.rack li.h3u a, ul.rack li.h3u span { padding: 20px 0; }
ul.rack li.h4u { height: 80px; }
ul.rack li.h4u a, ul.rack li.h4u span { padding: 30px 0; }
ul.rack li.h5u { height: 100px; }
ul.rack li.h5u a, ul.rack li.h5u span { padding: 40px 0; }
ul.rack li.h6u { height: 120px; }
ul.rack li.h6u a, ul.rack li.h6u span { padding: 50px 0; }
ul.rack li.h7u { height: 140px; }
ul.rack li.h7u a, ul.rack li.h7u span { padding: 60px 0; }
ul.rack li.h8u { height: 160px; }
ul.rack li.h8u a, ul.rack li.h8u span { padding: 70px 0; }
ul.rack li.h9u { height: 180px; }
ul.rack li.h9u a, ul.rack li.h9u span { padding: 80px 0; }
ul.rack li.h10u { height: 200px; }
ul.rack li.h10u a, ul.rack li.h10u span { padding: 90px 0; }
ul.rack li.h11u { height: 220px; }
ul.rack li.h11u a, ul.rack li.h11u span { padding: 100px 0; }
ul.rack li.h12u { height: 240px; }
ul.rack li.h12u a, ul.rack li.h12u span { padding: 110px 0; }
ul.rack li.h13u { height: 260px; }
ul.rack li.h13u a, ul.rack li.h13u span { padding: 120px 0; }
ul.rack li.h14u { height: 280px; }
ul.rack li.h14u a, ul.rack li.h14u span { padding: 130px 0; }
ul.rack li.h15u { height: 300px; }
ul.rack li.h15u a, ul.rack li.h15u span { padding: 140px 0; }
ul.rack li.h16u { height: 320px; }
ul.rack li.h16u a, ul.rack li.h16u span { padding: 150px 0; }
ul.rack li.occupied a {
color: #ffffff;
display: block;
font-weight: bold;
}
ul.rack li.occupied a:hover {
text-decoration: none;
}
ul.rack li.occupied span {
display: block;
}
ul.rack_near_face li {
border-bottom: 1px solid #e0e0e0;
}
ul.rack_near_face li.occupied {
color: #474747;
}
ul.rack_far_face li.occupied {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#f0f0f0 7px,
#f0f0f0 14px
);
color: #303030;
}
ul.rack_far_face li.blocked {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#ffc7c7 7px,
#ffc7c7 14px
);
color: #303030;
}
ul.rack_near_face li.empty a {
color: #0000ff;
display: none;
text-decoration: none;
}
ul.rack_near_face li.empty:hover {
background-color: #ffffff;
}
ul.rack_near_face li.empty:hover a {
display: block;
}
/* Rack elevation colors (from http://flatuicolors.com) */
.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; }
.teal:hover { background-color: #16a085; }
.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; }
.green:hover { background-color: #27ae60; }
.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; }
.blue:hover { background-color: #2980b9; }
.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; }
.purple:hover { background-color: #8e44ad; }
.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; }
.yellow:hover { background-color: #f39c12; }
.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; }
.orange:hover { background-color: #d35400; }
.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; }
.red:hover { background-color: #c0392b; }
.light_gray { background-color: #ecf0f1; border-bottom: 1px solid #bdc3c7; }
.light_gray:hover { background-color: #bdc3c7; }
.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; }
.medium_gray:hover { background-color: #7f8c8d; }
.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; }
.dark_gray:hover { background-color: #2c3e50; }
/* Misc */
.panel table>thead>tr>th {
border-bottom: 0;
}
ul.nav-tabs, ul.nav-pills {
margin-bottom: 20px;
}
.panel .list-group {
max-height: 400px;
overflow: auto;
}
/* Fix progress bar margin inside table cells */
td .progress {
margin-bottom: 0;
}
textarea {
font-family: Consolas, Lucida Console, monospace;
}

View File

@ -0,0 +1,116 @@
$(document).ready(function() {
// "Select all" checkbox in a table header
$('th input:checkbox').click(function (event) {
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
});
// Helper select fields
$('select.helper-parent').change(function () {
// Resolve child field by ID specified in parent
var child_field = $('#id_' + $(this).attr('child'));
// Wipe out any existing options within the child field
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
// If the parent has a value set, fetch a list of child options via the API and populate the child field with them
if ($(this).val()) {
// Construct the API request URL
var api_url = $(this).attr('child-source');
var parent_accessor = $(this).attr('parent-accessor');
if (parent_accessor) {
api_url += '?' + parent_accessor + '=' + $(this).val();
} else {
api_url += '?' + $(this).attr('name') + '_id=' + $(this).val();
}
var api_url_extra = $(this).attr('child-filter');
if (api_url_extra) {
api_url += '&' + api_url_extra;
}
var disabled_indicator = $(this).attr('disabled-indicator');
var disabled_exempt = child_field.attr('exempt');
var child_display = $(this).attr('child-display');
if (!child_display) {
child_display = 'name';
}
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
console.log(response);
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[child_display]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) {
option.attr("disabled", "disabled")
}
child_field.append(option);
});
}
});
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
// API select widget
$('select[filter-for]').change(function () {
// Resolve child field by ID specified in parent
var child_name = $(this).attr('filter-for');
var child_field = $('#id_' + child_name);
// Wipe out any existing options within the child field
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
if ($(this).val()) {
var api_url = child_field.attr('api-url');
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
// Gather the values of all other filter fields for this child
$("select[filter-for='" + child_name + "']").each(function() {
var filter_field = $(this);
if (filter_field.val()) {
api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val());
} else {
// Not all filters have been selected yet
return false;
}
});
// If all URL variables have been replaced, make the API call
if (api_url.search('{{') < 0) {
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled")
}
child_field.append(option);
});
}
});
}
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
});

View File

@ -0,0 +1,40 @@
$(document).ready(function() {
var search_field = $('#id_livesearch');
var search_key = search_field.attr('data-key');
var label = search_field.attr('data-label');
if (!label) {
label = 'name';
}
search_field.autocomplete({
source: function(request, response) {
$.ajax({
type: 'GET',
url: search_field.attr('data-source'),
data: search_key + '=' + request.term,
success: function(data) {
var choices = [];
$.each(data, function (index, choice) {
choices.push({
value: choice.id,
label: choice[label]
});
});
response(choices);
}
});
},
select: function(event, ui) {
event.preventDefault();
search_field.val(ui.item.label);
var real_field = $('#id_' + search_field.attr('data-field'));
real_field.empty();
real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
real_field.change();
// If the field has a parent helper, reset the parent to no selection
$('select[filter-for="' + real_field.attr('name') + '"]').val('');
},
minLength: 4,
delay: 500
});
});

View File

@ -0,0 +1,96 @@
$(document).ready(function() {
// Unlocking a secret
$('button.unlock-secret').click(function (event) {
var secret_id = $(this).attr('secret-id');
// Retrieve from storage or prompt for private key
var private_key = sessionStorage.getItem('private_key');
if (!private_key) {
$('#privkey_modal').modal('show');
} else {
unlock_secret(secret_id, private_key);
$(this).hide();
$(this).siblings('button.lock-secret').show();
}
});
// Locking a secret
$('button.lock-secret').click(function (event) {
var secret_id = $(this).attr('secret-id');
$('#secret_' + secret_id).html('********');
$(this).hide();
$(this).siblings('button.unlock-secret').show();
});
// Adding/editing a secret
$('form.requires-private-key').submit(function(event) {
var private_key = sessionStorage.getItem('private_key');
if (private_key) {
$('#id_private_key').val(private_key);
} else {
$('#privkey_modal').modal('show');
return false;
}
});
// Prompt the user to enter a private RSA key for decryption
$('#submit_privkey').click(function() {
var private_key = $('#user_privkey').val();
sessionStorage.setItem('private_key', private_key);
});
// Generate a new public/private key pair via the API
$('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show');
$.ajax({
url: '/api/secrets/generate-keys/',
type: 'GET',
dataType: 'json',
success: function (response, status) {
var public_key = response.public_key;
var private_key = response.private_key;
$('#new_pubkey').val(public_key);
$('#new_privkey').val(private_key);
},
error: function (xhr, ajaxOptions, thrownError) {
alert("There was an error generating a new key pair.");
}
});
});
// Enter a newly generated public key
$('#use_new_pubkey').click(function() {
var new_pubkey = $('#new_pubkey');
if (new_pubkey.val()) {
$('#id_public_key').val(new_pubkey.val());
}
});
// Retrieve a secret via the API
function unlock_secret(secret_id, private_key) {
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: '/api/secrets/secrets/' + secret_id + '/decrypt/',
type: 'POST',
data: {
private_key: private_key
},
dataType: 'json',
beforeSend: function(xhr, settings) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
},
success: function (response, status) {
var secret_plaintext = response.plaintext;
$('#secret_' + secret_id).html(secret_plaintext);
return true;
},
error: function (xhr, ajaxOptions, thrownError) {
if (xhr.status == 403) {
alert("Decryption failed: " + xhr.statusText);
}
}
});
}
});

View File

71
netbox/secrets/admin.py Normal file
View File

@ -0,0 +1,71 @@
from django.contrib import admin, messages
from django.shortcuts import redirect, render
from .forms import ActivateUserKeyForm
from .models import UserKey, SecretRole, Secret
@admin.register(UserKey)
class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created']
fields = ['user', 'public_key', 'is_active', 'last_modified']
readonly_fields = ['is_active', 'last_modified']
def get_readonly_fields(self, request, obj=None):
# Don't allow a user to modify an existing public key directly.
if obj and obj.public_key:
return ['public_key'] + self.readonly_fields
return self.readonly_fields
def get_actions(self, request):
# Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model.
actions = super(UserKeyAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
if not request.user.has_perm('secrets.activate_userkey'):
del actions['activate_selected']
return actions
def activate_selected(modeladmin, request, queryset):
"""
Enable bulk activation of UserKeys
"""
try:
my_userkey = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.error(request, "You do not have an active User Key.")
return redirect('/admin/secrets/userkey/')
if 'activate' in request.POST:
form = ActivateUserKeyForm(request.POST)
if form.is_valid():
try:
master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
for uk in form.cleaned_data['_selected_action']:
uk.activate(master_key)
return redirect('/admin/secrets/userkey/')
except ValueError:
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
else:
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
return render(request, 'activate_keys.html', {
'form': form,
})
activate_selected.short_description = "Activate selected user keys"
@admin.register(SecretRole)
class SecretRoleAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Secret)
class SecretAdmin(admin.ModelAdmin):
list_display = ['parent', 'role', 'name', 'created', 'last_modified']
fields = ['parent', 'role', 'name', 'hash', 'created', 'last_modified']
readonly_fields = ['parent', 'hash', 'created', 'last_modified']

View File

View File

@ -0,0 +1,39 @@
from rest_framework import serializers
from secrets.models import Secret, SecretRole
#
# SecretRoles
#
class SecretRoleSerializer(serializers.ModelSerializer):
class Meta:
model = SecretRole
fields = ['id', 'name', 'slug']
class SecretRoleNestedSerializer(SecretRoleSerializer):
class Meta(SecretRoleSerializer.Meta):
pass
#
# Secrets
#
# TODO: Serialize parent info
class SecretSerializer(serializers.ModelSerializer):
role = SecretRoleNestedSerializer()
class Meta:
model = Secret
fields = ['id', 'role', 'name', 'hash', 'created', 'last_modified']
class SecretNestedSerializer(SecretSerializer):
class Meta(SecretSerializer.Meta):
fields = ['id', 'name']

View File

@ -0,0 +1,20 @@
from django.conf.urls import url
from .views import *
urlpatterns = [
# Secrets
url(r'^secrets/$', SecretListView.as_view(), name='secret_list'),
url(r'^secrets/(?P<pk>\d+)/$', SecretDetailView.as_view(), name='secret_detail'),
url(r'^secrets/(?P<pk>\d+)/decrypt/$', SecretDecryptView.as_view(), name='secret_decrypt'),
# Secret roles
url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/(?P<pk>\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'),
# Miscellaneous
url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'),
]

104
netbox/secrets/api/views.py Normal file
View File

@ -0,0 +1,104 @@
from Crypto.PublicKey import RSA
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from rest_framework import generics
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from secrets.models import Secret, SecretRole, UserKey
from .serializers import SecretRoleSerializer, SecretSerializer
class SecretRoleListView(generics.ListAPIView):
"""
List all secret roles
"""
queryset = SecretRole.objects.all()
serializer_class = SecretRoleSerializer
class SecretRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single secret role
"""
queryset = SecretRole.objects.all()
serializer_class = SecretRoleSerializer
class SecretListView(generics.ListAPIView):
"""
List secrets (filterable)
"""
queryset = Secret.objects.select_related('role')
serializer_class = SecretSerializer
#filter_class = SecretFilter
permission_classes = [IsAuthenticated]
class SecretDetailView(generics.RetrieveAPIView):
"""
Retrieve a single Secret
"""
queryset = Secret.objects.select_related('role')
serializer_class = SecretSerializer
permission_classes = [IsAuthenticated]
class SecretDecryptView(APIView):
"""
Retrieve the plaintext from a stored Secret. The request must include a valid private key.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
secret = get_object_or_404(Secret, pk=pk)
private_key = request.POST.get('private_key')
if not private_key:
raise ValidationError("Private key is missing from request.")
# Retrieve the Secret's plaintext with the user's private key
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
return HttpResponseForbidden(reason="No UserKey found.")
if not uk.is_active():
return HttpResponseForbidden(reason="UserKey is inactive.")
# Attempt to decrypt the Secret.
master_key = uk.get_master_key(private_key)
if master_key is None:
return HttpResponseForbidden(reason="Invalid secret key.")
secret.decrypt(master_key)
return Response({
'plaintext': secret.plaintext,
})
class RSAKeyGeneratorView(APIView):
"""
Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
# Determine what size key to generate
key_size = request.GET.get('key_size', 2048)
if key_size not in range(2048, 4097, 256):
key_size = 2048
# Export RSA private and public keys in PEM format
key = RSA.generate(key_size)
private_key = key.exportKey('PEM')
public_key = key.publickey().exportKey('PEM')
return Response({
'private_key': private_key,
'public_key': public_key,
})

7
netbox/secrets/apps.py Normal file
View File

@ -0,0 +1,7 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class SecretsConfig(AppConfig):
name = 'secrets'

View File

@ -0,0 +1,24 @@
from django.contrib import messages
from django.shortcuts import redirect
from .models import UserKey
def userkey_required():
"""
Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of
Secrets).
"""
def _decorator(view):
def wrapped_view(request, *args, **kwargs):
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.warning(request, "This operation requires an active user key, but you don't have one.")
return redirect('users:userkey')
if not uk.is_active():
messages.warning(request, "This operation is not available. Your user key has not been activated.")
return redirect('users:userkey')
return view(request, *args, **kwargs)
return wrapped_view
return _decorator

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