diff --git a/docs/data-model/circuits.md b/docs/data-model/circuits.md index 226c62814..301400c38 100644 --- a/docs/data-model/circuits.md +++ b/docs/data-model/circuits.md @@ -2,7 +2,7 @@ The circuits component of NetBox deals with the management of long-haul Internet # 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. +A provider is any entity which provides some form of connectivity. While this obviously includes carriers which offer Internet and private transit service, 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), an account number, and contact information. @@ -14,7 +14,7 @@ A circuit represents a single physical data link connecting two endpoints. Each ### Circuit Types -Circuits are classified by type. For example: +Circuits are classified by type. For example, you might define circuit types for: * Internet transit * Out-of-band connectivity @@ -27,7 +27,7 @@ Circuit types are fully customizable. A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination can be tied to a site, or to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. +Each circuit termination is tied to a site, and optionally to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. !!! note A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit. diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index aa8673fb1..a0b05e9dd 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -2,61 +2,72 @@ Data center infrastructure management (DCIM) entails all physical assets: sites, # Sites -How you define sites will depend on the nature of your organization, but typically a site will equate 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. +How you choose to use sites will depend on the nature of your organization, but typically a site will equate to 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. -Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment. +Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment, and an Autonomous System (AS) number. + +### Regions + +Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. --- # 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. +The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack is assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U, but NetBox allows you to define racks of arbitrary 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. +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. The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches. ### 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. +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 represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported. ### Rack Roles -Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. +Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. + +### Rack Space Reservations + +Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). --- # 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). +A device type represents a particular hardware model that exists in the real world. 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). + +Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type. ### Manufacturers -Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. Manufacturers are used to group different models of device. +Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer. ### 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: +Each device type is assigned a number of component templates which define the physical interfaces a device has. These are: -* Console port templates -* Console server port templates -* Power port templates -* Power outlet templates -* Interface templates -* Device bay templates +* Console ports +* Console server ports +* Power ports +* Power outlets +* Interfaces +* Device bays -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: +Whenever a new device is created, it is automatically assigned components per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates: * 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. +Once component templates have been created, 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. +!!! note + 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. --- @@ -64,19 +75,19 @@ Note that assignment of components from templates occurs only at the time of dev Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and 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. -When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8. +When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8. This logic applies to racks with both ascending and descending unit numbering. 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. ### 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. +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, a device can belong to only one role. ### Platforms 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. +The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. ### Modules @@ -93,10 +104,11 @@ There are six types of device components which comprise all of the interconnecti * Interfaces * Device bays -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.) +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 classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. -Each type of connection can be classified 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. +Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description. Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. -Note that child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply. +!!! note + Child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply, which do not provide their own management plane. diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index dca6d7f03..58da76cee 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -2,7 +2,7 @@ This section entails features of NetBox which are not crucial to its primary fun # Custom Fields -Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. +Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. @@ -33,7 +33,15 @@ NetBox allows users to define custom templates that can be used when exporting o Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. -Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop. +Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +``` +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. @@ -44,10 +52,10 @@ A MIME type and file extension can optionally be defined for each export templat Here's an example device export template that will generate a simple Nagios configuration from a list of devices. ``` -{% for d in queryset %}{% if d.status and d.primary_ip %}define host{ +{% for device in queryset %}{% if device.status and device.primary_ip %}define host{ use generic-switch - host_name {{ d.name }} - address {{ d.primary_ip.address.ip }} + host_name {{ device.name }} + address {{ device.primary_ip.address.ip }} } {% endif %}{% endfor %} ``` @@ -74,9 +82,9 @@ define host{ # Graphs -NetBox does not generate graphs itself. This feature allows you to embed contextual graphs from an external resources inside certain NetBox views. Each embedded graph must be defined with the following parameters: +NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: -* **Type:** Interface, provider, or site. This determines where the graph will be displayed. +* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. * **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. * **Name:** The title to display above the graph. * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. @@ -86,7 +94,7 @@ NetBox does not generate graphs itself. This feature allows you to embed context NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. -Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend connectivity). +Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index ee54e74d2..8b6d53184 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -6,11 +6,14 @@ A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain 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. +!!! note + By default, NetBox allows for overlapping IP space both in the global table and within each VRF. Unique space enforcement can be toggled per-VRF as well as in the global table using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. + --- # Aggregates -IPv4 address space is organized as a hierarchy, with more-specific (smaller) prefix arranged as child nodes under less-specific (larger) prefixes. For example: +IP address space is organized as a hierarchy, with more-specific (smaller) prefixes arranged as child nodes under less-specific (larger) prefixes. For example: * 10.0.0.0/8 * 10.1.0.0/16 @@ -18,23 +21,23 @@ IPv4 address space is organized as a hierarchy, with more-specific (smaller) pre 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: +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 private IPv4 space set aside in RFC 1918. 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. +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. (Most organizations will not have a need to track IPv6 link local 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. +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. Total utilization for each aggregate is displayed in the aggregates list. -Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix. +Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. ### 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. You are free to define whichever RIRs you choose (or create your own). +Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). Each RIR can be annotated as representing only private space. --- @@ -44,7 +47,7 @@ A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 19 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. +A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. Each prefix may also be assigned a short description. ### Statuses @@ -52,7 +55,7 @@ Each prefix is assigned an operational status. This is one of the following: * Container - A summary of child prefixes * Active - Provisioned and in use -* Reserved - Earmarked for future use +* Reserved - Designated for future use * Deprecated - No longer in use ### Roles @@ -65,30 +68,32 @@ Whereas a status describes a prefix's operational state, a role describes its fu * Lab * Out-of-band -Role assignment is optional and you are free to create as many as you'd like. +Role assignment is optional and roles are fully customizable. --- # 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. +An IP address comprises a single address (either IPv4 or IPv6) and its subnet 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. +An IP address can 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 (for both IPv4 and IPv6). -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. +One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily to denote the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not supported. --- # VLANs -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. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role, and may include a short description. -Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. +### VLAN Groups + +VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site. --- # Services -A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.) +A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/data-model/secrets.md b/docs/data-model/secrets.md index ef82c196b..31c73bc92 100644 --- a/docs/data-model/secrets.md +++ b/docs/data-model/secrets.md @@ -24,11 +24,11 @@ Roles are also used to control access to secrets. Each role is assigned an arbit 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. +User keys may be created by users individually, however they are of no use until they have been activated by a user who already possesses an active user key. ## Creating the First User Key -When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the super user) must create a user key. This can be done by navigating to Profile > User Key. +When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the superuser) must create a user key. This can be done by navigating to Profile > User Key. To create a user key, you can either generate a new RSA key pair, or upload the public key belonging to a pair you already have. If generating a new key pair, **you must save the private key** locally before saving your new user key. Once your user key has been created, its public key will be displayed under your profile. diff --git a/docs/data-model/tenancy.md b/docs/data-model/tenancy.md index c9cfc997c..eb5fda168 100644 --- a/docs/data-model/tenancy.md +++ b/docs/data-model/tenancy.md @@ -1,10 +1,8 @@ -NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments. +NetBox supports the assignment of resources to tenant organizations. Typically, these are used to represent individual customers of or internal departments within the organization using NetBox. # Tenants -A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance. - -The following objects can be assigned to tenants: +A tenant represents a discrete organization. The following objects can be assigned to tenants: * Sites * Racks diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index afb36f464..193d7e74a 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -27,6 +27,12 @@ If you followed the original installation guide to set up gunicorn, be sure to c # cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py ``` +Copy the LDAP configuration if using LDAP: + +```no-highlight +# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py +``` + ## Option B: Clone the Git Repository (latest master release) This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch: diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa57a74dc..b07e87068 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -11,8 +11,8 @@ from .models import Provider, Circuit, CircuitType class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -31,7 +31,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Provider fields = ['name', 'account', 'asn'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | @@ -40,8 +42,8 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) provider_id = django_filters.ModelMultipleChoiceFilter( @@ -93,7 +95,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Circuit fields = ['install_date'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(cid__icontains=value) | Q(terminations__xconnect_id__icontains=value) | diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4b9e949f8..940ae939a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import Count -from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( @@ -64,6 +64,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider q = forms.CharField(required=False, label='Search') site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug') + asn = forms.IntegerField(required=False, label='ASN') # @@ -128,14 +129,23 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit q = forms.CharField(required=False, label='Search') - type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug') - provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', - null_option=(0, 'None')) - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), - to_field_name='slug') + type = FilterChoiceField( + queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug' + ) + provider = FilterChoiceField( + queryset=Provider.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug' + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug', + null_option=(0, 'None') + ) + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), + to_field_name='slug' + ) # @@ -143,19 +153,49 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): - 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}}', - display_field='display_name', attrs={'filter-for': 'interface'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + 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', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'interface'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + interface = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Interface', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', + disabled_indicator='is_connected' + ) ) - interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', - disabled_indicator='is_connected')) class Meta: model = CircuitTermination @@ -197,14 +237,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): # 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_termination', 'connected_as_a', - 'connected_as_b') + interfaces = Interface.objects.filter(device=self.data['device']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', '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_termination', 'connected_as_a', - 'connected_as_b') + interfaces = Interface.objects.filter(device=self.initial['device']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') else: interfaces = [] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c85fad1a1..1ffda899b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -31,7 +31,8 @@ class ProviderListView(ObjectListView): def provider(request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider) + circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\ + .prefetch_related('terminations__site') show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() return render(request, 'circuits/provider.html', { @@ -118,9 +119,17 @@ class CircuitListView(ObjectListView): def circuit(request, pk): - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first() - termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first() + circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) + termination_a = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_A + ).first() + termination_z = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_Z + ).first() return render(request, 'circuits/circuit.html', { 'circuit': circuit, @@ -224,9 +233,9 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): fields_initial = ['term_side'] template_name = 'circuits/circuittermination_edit.html' - def alter_obj(self, obj, args, kwargs): - if 'circuit' in kwargs: - obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'circuit' in url_kwargs: + obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) return obj def get_return_url(self, obj): diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index 8828b52c4..16f07dfcf 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -1,13 +1,24 @@ from django.contrib import admin from django.db.models import Count +from mptt.admin import MPTTModelAdmin + from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, + Site, ) +@admin.register(Region) +class RegionAdmin(MPTTModelAdmin): + list_display = ['name', 'parent', 'slug'] + prepopulated_fields = { + 'slug': ['name'], + } + + @admin.register(Site) class SiteAdmin(admin.ModelAdmin): list_display = ['name', 'slug', 'facility', 'asn'] @@ -37,6 +48,11 @@ class RackAdmin(admin.ModelAdmin): list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height'] +@admin.register(RackReservation) +class RackRackReservationAdmin(admin.ModelAdmin): + list_display = ['rack', 'units', 'description', 'user', 'created'] + + # # Device types # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f81f299af..d6162f48f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -4,23 +4,42 @@ from ipam.models import IPAddress from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, - SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT, + RACK_FACE_REAR, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer +# +# Regions +# + +class RegionNestedSerializer(serializers.ModelSerializer): + + class Meta: + model = Region + fields = ['id', 'name', 'slug'] + + +class RegionSerializer(serializers.ModelSerializer): + + class Meta: + model = Region + fields = ['id', 'name', 'slug', 'parent'] + + # # Sites # class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): + region = RegionNestedSerializer() tenant = TenantNestedSerializer() class Meta: model = Site - fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] @@ -70,6 +89,12 @@ class RackRoleNestedSerializer(RackRoleSerializer): # Racks # +class RackReservationNestedSerializer(serializers.ModelSerializer): + + class Meta: + model = RackReservation + fields = ['id', 'units', 'created', 'user', 'description'] + class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() @@ -92,10 +117,11 @@ class RackNestedSerializer(RackSerializer): class RackDetailSerializer(RackSerializer): front_units = serializers.SerializerMethodField() rear_units = serializers.SerializerMethodField() + reservations = RackReservationNestedSerializer(many=True) class Meta(RackSerializer.Meta): fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units'] + 'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) @@ -110,6 +136,18 @@ class RackDetailSerializer(RackSerializer): return units +# +# Rack reservations +# + +class RackReservationSerializer(serializers.ModelSerializer): + rack = RackNestedSerializer() + + class Meta: + model = RackReservation + fields = ['id', 'rack', 'units', 'created', 'user', 'description'] + + # # Manufacturers # @@ -256,6 +294,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): device_role = DeviceRoleNestedSerializer() tenant = TenantNestedSerializer() platform = PlatformNestedSerializer() + site = SiteNestedSerializer() rack = RackNestedSerializer() primary_ip = DeviceIPAddressNestedSerializer() primary_ip4 = DeviceIPAddressNestedSerializer() @@ -264,9 +303,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Device - fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'comments', 'custom_fields'] + fields = [ + 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', + 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'comments', 'custom_fields', + ] def get_parent_device(self, obj): try: @@ -368,13 +409,24 @@ class PowerPortNestedSerializer(PowerPortSerializer): # Interfaces # -class InterfaceSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() +class LAGInterfaceNestedSerializer(serializers.ModelSerializer): form_factor = serializers.ReadOnlyField(source='get_form_factor_display') class Meta: model = Interface - fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected'] + fields = ['id', 'name', 'form_factor'] + + +class InterfaceSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + form_factor = serializers.ReadOnlyField(source='get_form_factor_display') + lag = LAGInterfaceNestedSerializer() + + class Meta: + model = Interface + fields = [ + 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected', + ] class InterfaceNestedSerializer(InterfaceSerializer): @@ -388,8 +440,10 @@ class InterfaceDetailSerializer(InterfaceSerializer): connected_interface = InterfaceSerializer() class Meta(InterfaceSerializer.Meta): - fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', - 'connected_interface'] + fields = [ + 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected', + 'connected_interface', + ] # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 23787f4b4..0b9052d82 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -8,6 +8,10 @@ from .views import * urlpatterns = [ + # Regions + url(r'^regions/$', RegionListView.as_view(), name='region_list'), + url(r'^regions/(?P\d+)/$', RegionDetailView.as_view(), name='region_detail'), + # Sites url(r'^sites/$', SiteListView.as_view(), name='site_list'), url(r'^sites/(?P\d+)/$', SiteDetailView.as_view(), name='site_detail'), @@ -27,6 +31,10 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), + # Rack reservations + url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'), + url(r'^rack-reservations/(?P\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'), + # Manufacturers url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/(?P\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e76ec82ad..042057c70 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -10,8 +10,9 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, - InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, + Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, + VIRTUAL_IFACE_TYPES, ) from dcim import filters from extras.api.views import CustomFieldModelAPIView @@ -21,6 +22,26 @@ from .exceptions import MissingFilterException from . import serializers +# +# Regions +# + +class RegionListView(generics.ListAPIView): + """ + List all regions + """ + queryset = Region.objects.all() + serializer_class = serializers.RegionSerializer + + +class RegionDetailView(generics.RetrieveAPIView): + """ + Retrieve a single region + """ + queryset = Region.objects.all() + serializer_class = serializers.RegionSerializer + + # # Sites # @@ -134,6 +155,27 @@ class RackUnitListView(APIView): return Response(elevation) +# +# Rack reservations +# + +class RackReservationListView(generics.ListAPIView): + """ + List all rack reservation + """ + queryset = RackReservation.objects.all() + serializer_class = serializers.RackReservationSerializer + filter_class = filters.RackReservationFilter + + +class RackReservationDetailView(generics.RetrieveAPIView): + """ + Retrieve a single rack reservation + """ + queryset = RackReservation.objects.all() + serializer_class = serializers.RackReservationSerializer + + # # Manufacturers # @@ -337,9 +379,9 @@ class InterfaceListView(generics.ListAPIView): # 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) + queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) elif iface_type == 'virtual': - queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL) + queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) elif iface_type is not None: queryset = queryset.empty() diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 79024b605..eca792a12 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -7,16 +7,28 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( - ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, - Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, + Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, + VIRTUAL_IFACE_TYPES, ) class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) + region_id = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + label='Region (ID)', + ) + region = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + to_field_name='slug', + label='Region (slug)', + ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), @@ -33,9 +45,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Site fields = ['q', 'name', 'facility', 'asn'] - def search(self, queryset, value): - qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \ - Q(shipping_address__icontains=value) | Q(comments__icontains=value) + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(facility__icontains=value) | + Q(physical_address__icontains=value) | + Q(shipping_address__icontains=value) | + Q(comments__icontains=value) + ) try: qs_filter |= Q(asn=int(value.strip())) except ValueError: @@ -58,11 +77,12 @@ class RackGroupFilter(django_filters.FilterSet): class Meta: model = RackGroup + fields = ['name'] class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -114,7 +134,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Rack fields = ['u_height'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(facility_id__icontains=value) | @@ -122,9 +144,21 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): ) +class RackReservationFilter(django_filters.FilterSet): + rack_id = django_filters.ModelMultipleChoiceFilter( + name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + + class Meta: + model = RackReservation + fields = ['rack', 'user'] + + class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( @@ -141,10 +175,13 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = DeviceType - fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', - 'subdevice_role'] + fields = [ + 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', + ] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(manufacturer__name__icontains=value) | Q(model__icontains=value) | @@ -154,21 +191,21 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) - mac_address = django_filters.MethodFilter( - action='_mac_address', + mac_address = django_filters.CharFilter( + method='_mac_address', label='MAC address', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='rack__site', + name='site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='rack__site__slug', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', @@ -178,7 +215,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=RackGroup.objects.all(), label='Rack group (ID)', ) - rack_id = django_filters.ModelMultipleChoiceFilter( + rack_id = NullableModelMultipleChoiceFilter( name='rack', queryset=Rack.objects.all(), label='Rack (ID)', @@ -259,7 +296,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Device fields = ['name', 'serial', 'asset_tag'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(serial__icontains=value.strip()) | @@ -268,7 +307,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(comments__icontains=value) ).distinct() - def _mac_address(self, queryset, value): + def _mac_address(self, queryset, name, value): value = value.strip() if not value: return queryset @@ -362,58 +401,72 @@ class InterfaceFilter(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + type = django_filters.CharFilter( + method='filter_type', + label='Interface type', + ) class Meta: model = Interface fields = ['name'] + def filter_type(self, queryset, name, value): + value = value.strip().lower() + if value == 'physical': + return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) + elif value == 'virtual': + return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) + elif value == 'lag': + return queryset.filter(form_factor=IFACE_FF_LAG) + return queryset + class ConsoleConnectionFilter(django_filters.FilterSet): - site = django_filters.MethodFilter( - action='filter_site', + site = django_filters.CharFilter( + method='filter_site', label='Site (slug)', ) class Meta: model = ConsoleServerPort + fields = [] - def filter_site(self, queryset, value): - value = value.strip() - if not value: + def filter_site(self, queryset, name, value): + if not value.strip(): return queryset - return queryset.filter(cs_port__device__rack__site__slug=value) + return queryset.filter(cs_port__device__site__slug=value) class PowerConnectionFilter(django_filters.FilterSet): - site = django_filters.MethodFilter( - action='filter_site', + site = django_filters.CharFilter( + method='filter_site', label='Site (slug)', ) class Meta: model = PowerOutlet + fields = [] - def filter_site(self, queryset, value): - value = value.strip() - if not value: + def filter_site(self, queryset, name, value): + if not value.strip(): return queryset - return queryset.filter(power_outlet__device__rack__site__slug=value) + return queryset.filter(power_outlet__device__site__slug=value) class InterfaceConnectionFilter(django_filters.FilterSet): - site = django_filters.MethodFilter( - action='filter_site', + site = django_filters.CharFilter( + method='filter_site', label='Site (slug)', ) class Meta: model = InterfaceConnection + fields = [] - def filter_site(self, queryset, value): - value = value.strip() - if not value: + def filter_site(self, queryset, name, value): + if not value.strip(): return queryset return queryset.filter( - Q(interface_a__device__rack__site__slug=value) | - Q(interface_b__device__rack__site__slug=value) + Q(interface_a__device__site__slug=value) | + Q(interface_b__device__site__slug=value) ) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 7c011eb89..4a9eb15e4 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -1915,6 +1915,7 @@ "platform": 1, "name": "test1-edge1", "serial": "5555555555", + "site": 1, "rack": 1, "position": 1, "face": 0, @@ -1935,6 +1936,7 @@ "platform": 1, "name": "test1-core1", "serial": "", + "site": 1, "rack": 1, "position": 17, "face": 0, @@ -1955,6 +1957,7 @@ "platform": 1, "name": "test1-spine1", "serial": "", + "site": 1, "rack": 1, "position": 33, "face": 0, @@ -1975,6 +1978,7 @@ "platform": 1, "name": "test1-leaf1", "serial": "", + "site": 1, "rack": 1, "position": 34, "face": 0, @@ -1995,6 +1999,7 @@ "platform": 1, "name": "test1-leaf2", "serial": "9823478293748", + "site": 1, "rack": 2, "position": 34, "face": 0, @@ -2015,6 +2020,7 @@ "platform": 1, "name": "test1-spine2", "serial": "45649818158", + "site": 1, "rack": 2, "position": 33, "face": 0, @@ -2035,6 +2041,7 @@ "platform": 1, "name": "test1-edge2", "serial": "7567356345", + "site": 1, "rack": 2, "position": 1, "face": 0, @@ -2055,6 +2062,7 @@ "platform": 1, "name": "test1-core2", "serial": "67856734534", + "site": 1, "rack": 2, "position": 17, "face": 0, @@ -2075,6 +2083,7 @@ "platform": 2, "name": "test1-oob1", "serial": "98273942938", + "site": 1, "rack": 1, "position": 42, "face": 0, @@ -2095,6 +2104,7 @@ "platform": null, "name": "test1-pdu1", "serial": "", + "site": 1, "rack": 1, "position": null, "face": null, @@ -2115,6 +2125,7 @@ "platform": null, "name": "test1-pdu2", "serial": "", + "site": 1, "rack": 2, "position": null, "face": null, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9f6c7bde6..1bcaa7a63 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,6 +1,9 @@ import re +from mptt.forms import TreeNodeChoiceField + from django import forms +from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ValidationError from django.db.models import Count, Q @@ -8,18 +11,19 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from ipam.models import IPAddress from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, - ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, - SlugField, + APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, + CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, + SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, - Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, + Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, + VIRTUAL_IFACE_TYPES ) @@ -52,18 +56,42 @@ def validate_connection_status(value): raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) +class DeviceComponentForm(BootstrapMixin, forms.Form): + """ + Allow inclusion of the parent device as context for limiting field choices. + """ + def __init__(self, device, *args, **kwargs): + self.device = device + super(DeviceComponentForm, self).__init__(*args, **kwargs) + + +# +# Regions +# + +class RegionForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Region + fields = ['parent', 'name', 'slug'] + + # # Sites # class SiteForm(BootstrapMixin, CustomFieldForm): + region = TreeNodeChoiceField(queryset=Region.objects.all()) slug = SlugField() comments = CommentField() class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', + ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}), @@ -78,12 +106,22 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class SiteFromCSVForm(forms.ModelForm): - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) + region = forms.ModelChoiceField( + Region.objects.all(), to_field_name='name', required=False, error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) + tenant = forms.ModelChoiceField( + Tenant.objects.all(), to_field_name='name', required=False, error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + ] class SiteImportForm(BootstrapMixin, BulkImportForm): @@ -92,18 +130,27 @@ class SiteImportForm(BootstrapMixin, BulkImportForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) + region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') class Meta: - nullable_fields = ['tenant', 'asn'] + nullable_fields = ['region', 'tenant', 'asn'] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', - null_option=(0, 'None')) + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + required=False, + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + null_option=(0, 'None') + ) # @@ -234,13 +281,53 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug') - group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site') - .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None')) - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug', - null_option=(0, 'None')) - role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug', - null_option=(0, 'None')) + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('racks')), + to_field_name='slug' + ) + group_id = FilterChoiceField( + queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')), + label='Rack group', + null_option=(0, 'None') + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('racks')), + to_field_name='slug', + null_option=(0, 'None') + ) + role = FilterChoiceField( + queryset=RackRole.objects.annotate(filter_count=Count('racks')), + to_field_name='slug', + null_option=(0, 'None') + ) + + +# +# Rack reservations +# + +class RackReservationForm(BootstrapMixin, forms.ModelForm): + units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) + + class Meta: + model = RackReservation + fields = ['units', 'description'] + + def __init__(self, *args, **kwargs): + + super(RackReservationForm, self).__init__(*args, **kwargs) + + # Populate rack unit choices + self.fields['units'].widget.choices = self._get_unit_choices() + + def _get_unit_choices(self): + rack = self.instance.rack + reserved_units = [] + for resv in rack.reservations.exclude(pk=self.instance.pk): + for u in resv.units: + reserved_units.append(u) + unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units] + return unit_choices # @@ -284,8 +371,10 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): model = DeviceType q = forms.CharField(required=False, label='Search') - manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), - to_field_name='slug') + manufacturer = FilterChoiceField( + queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), + to_field_name='slug' + ) # @@ -302,7 +391,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsolePortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -316,7 +405,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -330,7 +419,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerPortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -344,7 +433,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerOutletTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -358,7 +447,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): +class InterfaceTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) mgmt_only = forms.BooleanField(required=False, label='OOB Management') @@ -382,7 +471,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): +class DeviceBayTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -416,7 +505,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class DeviceForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'position'} @@ -453,7 +542,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): if self.instance.pk: # Initialize helper selections - self.initial['site'] = self.instance.rack.site + self.initial['site'] = self.instance.site self.initial['manufacturer'] = self.instance.device_type.manufacturer # Compile list of choices for primary IPv4 and IPv6 addresses @@ -520,7 +609,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True self.fields['rack'].disabled = True - self.initial['site'] = self.instance.parent_bay.device.rack.site_id + self.initial['site'] = self.instance.parent_bay.device.site_id self.initial['rack'] = self.instance.parent_bay.device.rack_id @@ -556,7 +645,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={ 'invalid_choice': 'Invalid site name.', }) - rack_name = forms.CharField() + rack_name = forms.CharField(required=False) face = forms.CharField(required=False) class Meta(BaseDeviceFromCSVForm.Meta): @@ -649,7 +738,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): ) rack_group_id = FilterChoiceField( queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), - label='Rack Group', + label='Rack group', ) role = FilterChoiceField( queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), @@ -714,14 +803,18 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(BootstrapMixin, forms.Form): +class ConsolePortCreateForm(DeviceComponentForm): 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'}) + 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'}) @@ -786,22 +879,49 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): - 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', - display_field='display_name', - 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') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput(), + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'console_server', 'nullable': 'true'} + ) + ) + console_server = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Console Server', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True', + display_field='display_name', + 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', + ) ) - 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'] + fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] labels = { 'cs_port': 'Port', 'connection_status': 'Status', @@ -814,17 +934,22 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): 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.initial['site'] = self.instance.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.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) + 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) + self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], + device_type__is_console_server=True) else: - self.fields['console_server'].choices = [] + self.fields['console_server'].queryset = Device.objects.filter(site=self.instance.device.site, + rack__isnull=True, + device_type__is_console_server=True) # Initialize CS port choices if self.is_bound: @@ -849,27 +974,61 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): - 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}}', - display_field='display_name', 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') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput(), + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + 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 + ) ) - 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'] + fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] labels = { 'connection_status': 'Status', } @@ -878,7 +1037,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs) - self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site) + self.initial['site'] = consoleserverport.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.site) # Initialize device choices if self.is_bound and self.data.get('rack'): @@ -886,7 +1046,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): elif self.initial.get('rack', None): self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) else: - self.fields['device'].choices = [] + self.fields['device'].queryset = Device.objects.filter(site=consoleserverport.device.site, + rack__isnull=True) # Initialize port choices if self.is_bound: @@ -911,13 +1072,18 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(BootstrapMixin, forms.Form): +class PowerPortCreateForm(DeviceComponentForm): 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.'}) + 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'}) @@ -983,21 +1149,46 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): - 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', - display_field='display_name', 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') + site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput()) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'pdu', 'nullable': 'true'} + ) + ) + pdu = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='PDU', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True', + display_field='display_name', + 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' + ) ) - 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'] + fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] labels = { 'power_outlet': 'Outlet', 'connection_status': 'Status', @@ -1010,17 +1201,22 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): 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.initial['site'] = self.instance.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.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) + 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) + self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], + device_type__is_pdu=True) else: - self.fields['pdu'].choices = [] + self.fields['pdu'].queryset = Device.objects.filter(site=self.instance.device.site, + rack__isnull=True, + device_type__is_pdu=True) # Initialize power outlet choices if self.is_bound: @@ -1045,27 +1241,61 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletCreateForm(BootstrapMixin, forms.Form): +class PowerOutletCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') class PowerOutletConnectionForm(BootstrapMixin, forms.Form): - 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}}', - display_field='display_name', 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') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput() + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + 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 + ) ) - 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'] + fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] labels = { 'connection_status': 'Status', } @@ -1074,7 +1304,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): super(PowerOutletConnectionForm, self).__init__(*args, **kwargs) - self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site) + self.initial['site'] = poweroutlet.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.site) # Initialize device choices if self.is_bound and self.data.get('rack'): @@ -1082,7 +1313,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): elif self.initial.get('rack', None): self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) else: - self.fields['device'].choices = [] + self.fields['device'].queryset = Device.objects.filter(site=poweroutlet.device.site, + rack__isnull=True) # Initialize port choices if self.is_bound: @@ -1101,27 +1333,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] + fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] widgets = { 'device': forms.HiddenInput(), } + def __init__(self, *args, **kwargs): + super(InterfaceForm, self).__init__(*args, **kwargs) -class InterfaceCreateForm(BootstrapMixin, forms.Form): + # Limit LAG choices to interfaces belonging to this device + if self.is_bound: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device_id=self.data['device'], form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device=self.instance.device, form_factor=IFACE_FF_LAG + ) + + +class InterfaceCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') mac_address = MACAddressFormField(required=False, label='MAC Address') mgmt_only = forms.BooleanField(required=False, label='OOB Management') description = forms.CharField(max_length=100, required=False) + def __init__(self, *args, **kwargs): + super(InterfaceCreateForm, self).__init__(*args, **kwargs) + + # Limit LAG choices to interfaces belonging to this device + if self.device is not None: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device=self.device, form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.none() + class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['description'] + nullable_fields = ['lag', 'description'] + + def __init__(self, *args, **kwargs): + super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) + + # Limit LAG choices to interfaces which belong to the parent device. + if self.initial.get('device'): + self.fields['lag'].queryset = Interface.objects.filter( + device=self.initial['device'], form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].choices = [] # @@ -1129,22 +1399,55 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): - interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface') - site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select(attrs={'filter-for': 'rack_b'})) - rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}', - 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}}', - display_field='display_name', - 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_a = forms.ChoiceField( + choices=[], + widget=SelectWithDisabled, + label='Interface' + ) + site_b = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=forms.Select( + attrs={'filter-for': 'rack_b'} + ) + ) + rack_b = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site_b}}', + attrs={'filter-for': 'device_b', 'nullable': 'true'} + ) + ) + device_b = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}', + display_field='display_name', + 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' + ) ) - 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 @@ -1155,8 +1458,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): super(InterfaceConnectionForm, self).__init__(*args, **kwargs) # Initialize interface A choices - device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\ - .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') + device_a_interfaces = Interface.objects.filter(device=device_a).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', '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 ] @@ -1169,23 +1475,31 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): else: self.fields['rack_b'].choices = [] - # Initialize device_b choices if rack_b is set + # Initialize device_b choices if rack_b or site_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.is_bound and self.data.get('site_b'): + self.fields['device_b'].queryset = Device.objects.filter(site__pk=self.data['site_b'], rack__isnull=True) elif self.initial.get('rack_b'): self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b']) + elif self.initial.get('site_b'): + self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True) 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_termination', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.data['device_b']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', '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_termination', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) else: device_b_interfaces = [] self.fields['interface_b'].choices = [ @@ -1194,13 +1508,21 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): class InterfaceConnectionCSVForm(forms.Form): - device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device A not found.'}) + 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.'}) + 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.CharField(validators=[validate_connection_status]) + status = forms.CharField( + validators=[validate_connection_status] + ) def clean(self): @@ -1295,7 +1617,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(BootstrapMixin, forms.Form): +class DeviceBayCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') diff --git a/netbox/dcim/migrations/0026_add_rack_reservations.py b/netbox/dcim/migrations/0026_add_rack_reservations.py new file mode 100644 index 000000000..b9d4f8214 --- /dev/null +++ b/netbox/dcim/migrations/0026_add_rack_reservations.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 18:43 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0025_devicetype_add_interface_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='RackReservation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), + ('created', models.DateTimeField(auto_now_add=True)), + ('description', models.CharField(max_length=100)), + ('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')), + ('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created'], + }, + ), + ] diff --git a/netbox/dcim/migrations/0027_device_add_site.py b/netbox/dcim/migrations/0027_device_add_site.py new file mode 100644 index 000000000..12d85f53e --- /dev/null +++ b/netbox/dcim/migrations/0027_device_add_site.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:21 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0026_add_rack_reservations'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + ] diff --git a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py new file mode 100644 index 000000000..6e7c52114 --- /dev/null +++ b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:23 +from __future__ import unicode_literals + +from django.db import migrations + + +def copy_site_from_rack(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + for device in Device.objects.all(): + device.site = device.rack.site + device.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0027_device_add_site'), + ] + + operations = [ + migrations.RunPython(copy_site_from_rack), + ] diff --git a/netbox/dcim/migrations/0029_allow_rackless_devices.py b/netbox/dcim/migrations/0029_allow_rackless_devices.py new file mode 100644 index 000000000..83906fc76 --- /dev/null +++ b/netbox/dcim/migrations/0029_allow_rackless_devices.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:25 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0028_device_copy_rack_to_site'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AlterField( + model_name='device', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + ] diff --git a/netbox/dcim/migrations/0030_interface_add_lag.py b/netbox/dcim/migrations/0030_interface_add_lag.py new file mode 100644 index 000000000..6f5be67a4 --- /dev/null +++ b/netbox/dcim/migrations/0030_interface_add_lag.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-27 19:55 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0029_allow_rackless_devices'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='lag', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name=b'Parent LAG'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py new file mode 100644 index 000000000..d4fd4db5e --- /dev/null +++ b/netbox/dcim/migrations/0031_regions.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-28 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0030_interface_add_lag'), + ] + + operations = [ + migrations.CreateModel( + name='Region', + 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)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'), + ), + ] diff --git a/netbox/dcim/migrations/0032_device_increase_name_length.py b/netbox/dcim/migrations/0032_device_increase_name_length.py new file mode 100644 index 000000000..e11e75bab --- /dev/null +++ b/netbox/dcim/migrations/0032_device_increase_name_length.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-03-02 15:09 +from __future__ import unicode_literals + +from django.db import migrations +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0031_regions'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='name', + field=utilities.fields.NullableCharField(blank=True, max_length=64, null=True, unique=True), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d29ca745d..e2c24c8c9 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,8 +1,12 @@ from collections import OrderedDict +from mptt.models import MPTTModel, TreeForeignKey + from django.conf import settings +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -66,6 +70,7 @@ IFACE_ORDERING_CHOICES = [ # Virtual IFACE_FF_VIRTUAL = 0 +IFACE_FF_LAG = 200 # Ethernet IFACE_FF_100ME_FIXED = 800 IFACE_FF_1GE_FIXED = 1000 @@ -104,6 +109,7 @@ IFACE_FF_CHOICES = [ 'Virtual interfaces', [ [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], ] ], [ @@ -146,6 +152,7 @@ IFACE_FF_CHOICES = [ [IFACE_FF_E1, 'E1 (2.048 Mbps)'], [IFACE_FF_T3, 'T3 (45 Mbps)'], [IFACE_FF_E3, 'E3 (34 Mbps)'], + [IFACE_FF_E3, 'E3 (34 Mbps)'], ] ], [ @@ -165,6 +172,11 @@ IFACE_FF_CHOICES = [ ], ] +VIRTUAL_IFACE_TYPES = [ + IFACE_FF_VIRTUAL, + IFACE_FF_LAG, +] + STATUS_ACTIVE = True STATUS_OFFLINE = False STATUS_CHOICES = [ @@ -190,6 +202,29 @@ RPC_CLIENT_CHOICES = [ ] +# +# Regions +# + +@python_2_unicode_compatible +class Region(MPTTModel): + """ + Sites can be grouped within geographic Regions. + """ + parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True) + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class MPTTMeta: + order_insertion_by = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + + # # Sites # @@ -208,7 +243,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT) + region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) + tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT) facility = models.CharField(max_length=50, blank=True) asn = ASNField(blank=True, null=True, verbose_name='ASN') physical_address = models.CharField(max_length=200, blank=True) @@ -234,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return csv_format([ self.name, self.slug, + self.region.name if self.region else None, self.tenant.name if self.tenant else None, self.facility, self.asn, @@ -368,6 +405,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ) }) + def save(self, *args, **kwargs): + + # Record the original site assignment for this rack. + _site_id = None + if self.pk: + _site_id = Rack.objects.get(pk=self.pk).site_id + + super(Rack, self).save(*args, **kwargs) + + # Update racked devices if the assigned Site has been changed. + if _site_id is not None and self.site_id != _site_id: + Device.objects.filter(rack=self).update(site_id=self.site.pk) + def to_csv(self): return csv_format([ self.site.name, @@ -478,6 +528,50 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): return int(float(self.u_height - u_available) / self.u_height * 100) +@python_2_unicode_compatible +class RackReservation(models.Model): + """ + One or more reserved units within a Rack. + """ + rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE) + units = ArrayField(models.PositiveSmallIntegerField()) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT) + description = models.CharField(max_length=100) + + class Meta: + ordering = ['created'] + + def __str__(self): + return u"Reservation for rack {}".format(self.rack) + + def clean(self): + + if self.units: + + # Validate that all specified units exist in the Rack. + invalid_units = [u for u in self.units if u not in self.rack.units] + if invalid_units: + raise ValidationError({ + 'units': u"Invalid unit(s) for {}U rack: {}".format( + self.rack.u_height, + ', '.join([str(u) for u in invalid_units]), + ), + }) + + # Check that none of the units has already been reserved for this Rack. + reserved_units = [] + for resv in self.rack.reservations.exclude(pk=self.pk): + reserved_units += resv.units + conflicting_units = [u for u in self.units if u in reserved_units] + if conflicting_units: + raise ValidationError({ + 'units': 'The following units have already been reserved: {}'.format( + ', '.join([str(u) for u in conflicting_units]), + ) + }) + + # # Device Types # @@ -821,11 +915,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel): device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) tenant = models.ForeignKey(Tenant, blank=True, null=True, 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) + name = NullableCharField(max_length=64, blank=True, null=True, unique=True) serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', help_text='A unique tag used to identify this device') - rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) + site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) + rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device') @@ -852,41 +947,59 @@ class Device(CreatedUpdatedModel, CustomFieldModel): def clean(self): + # Validate site/rack combination + if self.rack and self.site != self.rack.site: + raise ValidationError({ + 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site), + }) + + if self.rack is None: + if self.face is not None: + raise ValidationError({ + 'face': "Cannot select a rack face without assigning a rack.", + }) + if self.position: + raise ValidationError({ + 'face': "Cannot select a rack position without assigning a rack.", + }) + # Validate position/face combination if self.position and self.face is None: raise ValidationError({ - 'face': "Must specify rack face when defining rack position." + 'face': "Must specify rack face when defining rack position.", }) - try: - # Child devices cannot be assigned to a rack face/unit - if self.device_type.is_child_device and self.face is not None: - raise ValidationError({ - 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent " - "device." - }) - if self.device_type.is_child_device and self.position: - raise ValidationError({ - 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the " - "parent device." - }) + if self.rack: - # 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: + # Child devices cannot be assigned to a rack face/unit + if self.device_type.is_child_device and self.face is not None: raise ValidationError({ - 'position': "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) + 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent " + "device." + }) + if self.device_type.is_child_device and self.position: + raise ValidationError({ + 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the " + "parent device." }) - except Rack.DoesNotExist: - pass - except DeviceType.DoesNotExist: - pass + # 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({ + 'position': "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 + + except DeviceType.DoesNotExist: + pass def save(self, *args, **kwargs): @@ -934,8 +1047,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.platform.name if self.platform else None, self.serial, self.asset_tag, - self.rack.site.name, - self.rack.name, + self.site.name, + self.rack.name if self.rack else None, self.position, self.get_face_display(), ]) @@ -984,6 +1097,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel): return RPC_CLIENTS.get(self.platform.rpc_client) +# +# Console ports +# + @python_2_unicode_compatible class ConsolePort(models.Model): """ @@ -1013,6 +1130,10 @@ class ConsolePort(models.Model): ]) +# +# Console server ports +# + class ConsoleServerPortManager(models.Manager): def get_queryset(self): @@ -1045,6 +1166,10 @@ class ConsoleServerPort(models.Model): return self.name +# +# Power ports +# + @python_2_unicode_compatible class PowerPort(models.Model): """ @@ -1064,8 +1189,8 @@ class PowerPort(models.Model): return self.name # Used for connections export - def csv_format(self): - return ','.join([ + def to_csv(self): + return csv_format([ self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None, self.device.identifier, @@ -1074,6 +1199,10 @@ class PowerPort(models.Model): ]) +# +# Power outlets +# + class PowerOutletManager(models.Manager): def get_queryset(self): @@ -1100,6 +1229,10 @@ class PowerOutlet(models.Model): return self.name +# +# Interfaces +# + @python_2_unicode_compatible class Interface(models.Model): """ @@ -1107,6 +1240,8 @@ class Interface(models.Model): of an InterfaceConnection. """ device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) + lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL, + verbose_name='Parent LAG') name = models.CharField(max_length=30) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') @@ -1125,15 +1260,42 @@ class Interface(models.Model): def clean(self): - if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: + # Virtual interfaces cannot be connected + if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected: raise ValidationError({ 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " "interface or choose a physical form factor." }) + # An interface's LAG must belong to the same device + if self.lag and self.lag.device != self.device: + raise ValidationError({ + 'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format( + self.lag.name, self.lag.device.name + ) + }) + + # A virtual interface cannot have a parent LAG + if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None: + raise ValidationError({ + 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) + }) + + # Only a LAG can have LAG members + if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): + raise ValidationError({ + 'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format( + u", ".join([iface.name for iface in self.member_interfaces.all()]) + ) + }) + @property - def is_physical(self): - return self.form_factor != IFACE_FF_VIRTUAL + def is_virtual(self): + return self.form_factor in VIRTUAL_IFACE_TYPES + + @property + def is_lag(self): + return self.form_factor == IFACE_FF_LAG @property def is_connected(self): @@ -1197,6 +1359,10 @@ class InterfaceConnection(models.Model): ]) +# +# Device bays +# + @python_2_unicode_compatible class DeviceBay(models.Model): """ @@ -1227,6 +1393,10 @@ class DeviceBay(models.Model): raise ValidationError("Cannot install a device into itself.") +# +# Modules +# + @python_2_unicode_compatible class Module(models.Model): """ diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 442e2f8fb..9773be9dc 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,10 +6,28 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, Site, + RackGroup, Region, Site, ) +REGION_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +SITE_REGION_LINK = """ +{% if record.region %} + {{ record.region }} +{% else %} + — +{% endif %} +""" + COLOR_LABEL = """ """ @@ -20,6 +38,12 @@ DEVICE_LINK = """ """ +REGION_ACTIONS = """ +{% if perms.dcim.change_region %} + +{% endif %} +""" + RACKGROUP_ACTIONS = """ {% if perms.dcim.change_rackgroup %} @@ -70,12 +94,38 @@ STATUS_ICON = """ {% endif %} """ +DEVICE_PRIMARY_IP = """ +{{ record.primary_ip6.address.ip|default:"" }} +{% if record.primary_ip6 and record.primary_ip4 %}
{% endif %} +{{ record.primary_ip4.address.ip|default:"" }} +""" + UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} """ +# +# Regions +# + +class RegionTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False) + site_count = tables.Column(verbose_name='Sites') + slug = tables.Column(verbose_name='Slug') + actions = tables.TemplateColumn( + template_code=REGION_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Region + fields = ('pk', 'name', 'site_count', 'slug', 'actions') + + # # Sites # @@ -84,6 +134,7 @@ class SiteTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') facility = tables.Column(verbose_name='Facility') + region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') asn = tables.Column(verbose_name='ASN') rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') @@ -94,8 +145,10 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site - fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', - 'vlan_count', 'circuit_count') + fields = ( + 'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', + 'vlan_count', 'circuit_count', + ) # @@ -311,14 +364,13 @@ class DeviceTable(BaseTable): status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], - verbose_name='Site') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', text=lambda record: record.device_type.full_name) primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address', - template_code="{{ record.primary_ip.address.ip }}") + template_code=DEVICE_PRIMARY_IP) class Meta(BaseTable.Meta): model = Device @@ -328,8 +380,7 @@ class DeviceTable(BaseTable): class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], - verbose_name='Site') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') position = tables.Column(verbose_name='Position') device_role = tables.Column(verbose_name='Role') diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 0f7d1bbe3..3c56ab109 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -17,6 +17,7 @@ class SiteTest(APITestCase): 'id', 'name', 'slug', + 'region', 'tenant', 'facility', 'asn', @@ -151,6 +152,7 @@ class RackTest(APITestCase): 'width', 'u_height', 'desc_units', + 'reservations', 'comments', 'custom_fields', 'front_units', @@ -345,6 +347,7 @@ class DeviceTest(APITestCase): 'platform', 'serial', 'asset_tag', + 'site', 'rack', 'position', 'face', @@ -416,6 +419,9 @@ class DeviceTest(APITestCase): 'primary_ip4_family', 'primary_ip4_id', 'primary_ip6', + 'site_id', + 'site_name', + 'site_slug', 'rack_display_name', 'rack_facility_id', 'rack_id', @@ -571,6 +577,7 @@ class InterfaceTest(APITestCase): 'device', 'name', 'form_factor', + 'lag', 'mac_address', 'mgmt_only', 'description', @@ -584,6 +591,7 @@ class InterfaceTest(APITestCase): 'device', 'name', 'form_factor', + 'lag', 'mac_address', 'mgmt_only', 'description', diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2f3d8def6..d1b721cb0 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -6,14 +6,14 @@ class RackTestCase(TestCase): def setUp(self): - site = Site.objects.create( + self.site = Site.objects.create( name='TestSite1', slug='my-test-site' ) self.rack = Rack.objects.create( name='TestRack1', facility_id='A101', - site=site, + site=self.site, u_height=42 ) self.manufacturer = Manufacturer.objects.create( @@ -56,29 +56,29 @@ class RackTestCase(TestCase): 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, + site=self.site, + rack=self.rack, position=10, face=RACK_FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(rack1.units), list(reversed(range(1, 43)))) + self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) # Validate inventory (front face) - rack1_inventory_front = rack1.get_front_elevation() + rack1_inventory_front = self.rack.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() + rack1_inventory_rear = self.rack.get_rear_elevation() self.assertEqual(rack1_inventory_rear[-10]['device'], device1) del(rack1_inventory_rear[-10]) for u in rack1_inventory_rear: @@ -89,6 +89,7 @@ class RackTestCase(TestCase): name='TestPDU', device_role=self.role.get('PDU'), device_type=self.device_type.get('cc5000'), + site=self.site, rack=self.rack, position=None, face=None, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 58bc91802..7fde6e9b3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -8,6 +8,12 @@ from . import views urlpatterns = [ + # Regions + url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), + url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'), + url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), + # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'), @@ -29,6 +35,10 @@ urlpatterns = [ url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), + # Rack reservations + url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), @@ -38,6 +48,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', views.rack, name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), + url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 17f74eae3..02d9acd10 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackRole, Site, + RackReservation, RackRole, Region, Site, ) @@ -66,11 +66,12 @@ class ComponentCreateView(View): def get(self, request, pk): parent = get_object_or_404(self.parent_model, pk=pk) + form = self.form(parent, initial=request.GET) return render(request, 'dcim/device_component_add.html', { 'parent': parent, 'component_type': self.model._meta.verbose_name, - 'form': self.form(initial=request.GET), + 'form': form, 'return_url': parent.get_absolute_url(), }) @@ -78,7 +79,7 @@ class ComponentCreateView(View): parent = get_object_or_404(self.parent_model, pk=pk) - form = self.form(request.POST) + form = self.form(parent, request.POST) if form.is_valid(): new_components = [] @@ -128,12 +129,37 @@ class ComponentDeleteView(ObjectDeleteView): return obj.device.get_absolute_url() +# +# Regions +# + +class RegionListView(ObjectListView): + queryset = Region.objects.annotate(site_count=Count('sites')) + table = tables.RegionTable + template_name = 'dcim/region_list.html' + + +class RegionEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_region' + model = Region + form_class = forms.RegionForm + + def get_return_url(self, obj): + return reverse('dcim:region_list') + + +class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_region' + cls = Region + default_return_url = 'dcim:region_list' + + # # Sites # class SiteListView(ObjectListView): - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm table = tables.SiteTable @@ -142,7 +168,7 @@ class SiteListView(ObjectListView): def site(request, slug): - site = get_object_or_404(Site, slug=slug) + site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) stats = { 'rack_count': Rack.objects.filter(site=site).count(), 'device_count': Device.objects.filter(rack__site=site).count(), @@ -262,15 +288,23 @@ class RackListView(ObjectListView): def rack(request, pk): - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ .select_related('device_type__manufacturer') next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() + reservations = RackReservation.objects.filter(rack=rack) + reserved_units = {} + for r in reservations: + for u in r.units: + reserved_units[u] = r + return render(request, 'dcim/rack.html', { 'rack': rack, + 'reservations': reservations, + 'reserved_units': reserved_units, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -317,6 +351,33 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'dcim:rack_list' +# +# Rack reservations +# + +class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_rackreservation' + model = RackReservation + form_class = forms.RackReservationForm + + def alter_obj(self, obj, request, args, kwargs): + if not obj.pk: + obj.rack = get_object_or_404(Rack, pk=kwargs['rack']) + obj.user = request.user + return obj + + def get_return_url(self, obj): + return obj.rack.get_absolute_url() + + +class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rackreservation' + model = RackReservation + + def get_return_url(self, obj): + return obj.rack.get_absolute_url() + + # # Manufacturers # @@ -592,7 +653,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceListView(ObjectListView): - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site', + queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6') filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm @@ -602,7 +663,9 @@ class DeviceListView(ObjectListView): def device(request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(Device.objects.select_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' + ), pk=pk) console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) @@ -1376,7 +1439,7 @@ def interfaceconnection_add(request, pk): else: form = forms.InterfaceConnectionForm(device, initial={ 'interface_a': request.GET.get('interface_a', None), - 'site_b': request.GET.get('site_b', device.rack.site), + 'site_b': request.GET.get('site_b', device.site), 'rack_b': request.GET.get('rack_b', None), 'device_b': request.GET.get('device_b', None), 'interface_b': request.GET.get('interface_b', None), @@ -1517,9 +1580,9 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView): model = Module form_class = forms.ModuleForm - def alter_obj(self, obj, args, kwargs): - if 'device' in kwargs: - obj.device = get_object_or_404(Device, pk=kwargs['device']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'device' in url_kwargs: + obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 00aeb514b..da07f68d9 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -6,7 +6,7 @@ from django.db import models from .formfields import IPFormField from .lookups import ( EndsWith, IEndsWith, IRegex, IStartsWith, NetContained, NetContainedOrEqual, NetContains, NetContainsOrEquals, - NetHost, Regex, StartsWith, + NetHost, NetMaskLength, Regex, StartsWith, ) @@ -67,6 +67,7 @@ IPNetworkField.register_lookup(NetContainedOrEqual) IPNetworkField.register_lookup(NetContains) IPNetworkField.register_lookup(NetContainsOrEquals) IPNetworkField.register_lookup(NetHost) +IPNetworkField.register_lookup(NetMaskLength) class IPAddressField(BaseIPField): @@ -90,3 +91,4 @@ IPAddressField.register_lookup(NetContainedOrEqual) IPAddressField.register_lookup(NetContains) IPAddressField.register_lookup(NetContainsOrEquals) IPAddressField.register_lookup(NetHost) +IPAddressField.register_lookup(NetMaskLength) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a0dc9f633..42809b954 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,15 +13,10 @@ from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLAN class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) - name = django_filters.CharFilter( - name='name', - lookup_type='icontains', - label='Name', - ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), @@ -34,7 +29,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(rd__icontains=value) | @@ -43,7 +40,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VRF - fields = ['rd'] + fields = ['name', 'rd'] class RIRFilter(django_filters.FilterSet): @@ -54,8 +51,8 @@ class RIRFilter(django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) rir_id = django_filters.ModelMultipleChoiceFilter( @@ -74,7 +71,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Aggregate fields = ['family', 'date_added'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset qs_filter = Q(description__icontains=value) try: prefix = str(IPNetwork(value.strip()).cidr) @@ -85,14 +84,18 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) - parent = django_filters.MethodFilter( - action='search_by_parent', + parent = django_filters.CharFilter( + method='search_by_parent', label='Parent prefix', ) + mask_length = django_filters.NumberFilter( + method='filter_mask_length', + label='Mask length', + ) vrf_id = NullableModelMultipleChoiceFilter( name='vrf_id', queryset=VRF.objects.all(), @@ -151,7 +154,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): model = Prefix fields = ['family', 'status'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset qs_filter = Q(description__icontains=value) try: prefix = str(IPNetwork(value.strip()).cidr) @@ -160,7 +165,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) - def search_by_parent(self, queryset, value): + def search_by_parent(self, queryset, name, value): value = value.strip() if not value: return queryset @@ -170,34 +175,25 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _tenant(self, queryset, value): - if str(value) == '': + def filter_mask_length(self, queryset, name, value): + if not value: return queryset - return queryset.filter( - Q(tenant__slug=value) | - Q(tenant__isnull=True, vrf__tenant__slug=value) - ) - - def _tenant_id(self, queryset, value): - try: - value = int(value) - except ValueError: - return queryset.none() - return queryset.filter( - Q(tenant__pk=value) | - Q(tenant__isnull=True, vrf__tenant__pk=value) - ) + return queryset.filter(prefix__net_mask_length=value) class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) - parent = django_filters.MethodFilter( - action='search_by_parent', + parent = django_filters.CharFilter( + method='search_by_parent', label='Parent prefix', ) + mask_length = django_filters.NumberFilter( + method='filter_mask_length', + label='Mask length', + ) vrf_id = NullableModelMultipleChoiceFilter( name='vrf_id', queryset=VRF.objects.all(), @@ -239,9 +235,11 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['q', 'family', 'status'] + fields = ['family', 'status'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset qs_filter = Q(description__icontains=value) try: ipaddress = str(IPNetwork(value.strip())) @@ -250,25 +248,30 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) - def search_by_parent(self, queryset, value): + def search_by_parent(self, queryset, name, value): value = value.strip() if not value: return queryset try: - query = str(IPNetwork(value).cidr) + query = str(IPNetwork(value.strip()).cidr) return queryset.filter(address__net_contained_or_equal=query) except AddrFormatError: return queryset.none() + def filter_mask_length(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(address__net_mask_length=value) + class VLANGroupFilter(django_filters.FilterSet): - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + site = NullableModelMultipleChoiceFilter( + name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -276,20 +279,21 @@ class VLANGroupFilter(django_filters.FilterSet): class Meta: model = VLANGroup + fields = ['name'] class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + site = NullableModelMultipleChoiceFilter( + name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -305,15 +309,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group', ) - name = django_filters.CharFilter( - name='name', - lookup_type='icontains', - label='Name', - ) - vid = django_filters.NumberFilter( - name='vid', - label='VLAN number (1-4095)', - ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), @@ -339,12 +334,14 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VLAN - fields = ['status'] + fields = ['name', 'vid', 'status'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset qs_filter = Q(name__icontains=value) | Q(description__icontains=value) try: - qs_filter |= Q(vid=int(value)) + qs_filter |= Q(vid=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4b9d8ddf5..681a3c3a0 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -21,6 +21,12 @@ IP_FAMILY_CHOICES = [ (6, 'IPv6'), ] +PREFIX_MASK_LENGTH_CHOICES = [ + ('', '---------'), +] + [(i, i) for i in range(1, 128)] + +IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)] + # # VRFs @@ -131,8 +137,11 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate q = forms.CharField(required=False, label='Search') family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug', - label='RIR') + rir = FilterChoiceField( + queryset=RIR.objects.annotate(filter_count=Count('aggregates')), + to_field_name='slug', + label='RIR' + ) # @@ -153,7 +162,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class PrefixForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'vlan'})) + widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'})) vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name')) @@ -173,7 +182,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) else: - self.fields['vlan'].choices = [] + self.fields['vlan'].queryset = VLAN.objects.filter(site=None) class PrefixFromCSVForm(forms.ModelForm): @@ -259,19 +268,33 @@ def prefix_status_choices(): class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix q = forms.CharField(required=False, label='Search') - parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={ + parent = forms.CharField(required=False, label='Parent prefix', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd', - label='VRF', null_option=(0, 'Global')) - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None')) + family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') + mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length') + vrf = FilterChoiceField( + queryset=VRF.objects.annotate(filter_count=Count('prefixes')), + to_field_name='rd', + label='VRF', + null_option=(0, 'Global') + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), + to_field_name='slug', + null_option=(0, 'None') + ) status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None')) - role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None')) + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('prefixes')), + to_field_name='slug', + null_option=(0, 'None') + ) + role = FilterChoiceField( + queryset=Role.objects.annotate(filter_count=Count('prefixes')), + to_field_name='slug', + null_option=(0, 'None') + ) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -307,10 +330,10 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): 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_site'] = self.instance.nat_inside.interface.device.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) + rack__site=nat_inside.interface.device.site) self.fields['nat_inside'].queryset = IPAddress.objects.filter( interface__device=nat_inside.interface.device) else: @@ -346,20 +369,54 @@ class IPAddressBulkAddForm(BootstrapMixin, forms.Form): class IPAddressAssignForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', - display_field='display_name', attrs={'filter-for': 'device'})) - device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', - display_field='display_name', attrs={'filter-for': 'interface'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + display_field='display_name', + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'interface'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + interface = forms.ModelChoiceField( + queryset=Interface.objects.all(), + label='Interface', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/interfaces/' + ) + ) + set_as_primary = forms.BooleanField( + label='Set as primary IP for device', + required=False ) - interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/')) - set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) def __init__(self, *args, **kwargs): @@ -453,11 +510,19 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd', - label='VRF', null_option=(0, 'Global')) - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), - to_field_name='slug', null_option=(0, 'None')) + family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') + mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length') + vrf = FilterChoiceField( + queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), + to_field_name='rd', + label='VRF', + null_option=(0, 'Global') + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), + to_field_name='slug', + null_option=(0, 'None') + ) status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) @@ -474,7 +539,11 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), + to_field_name='slug', + null_option=(0, 'Global') + ) # @@ -490,7 +559,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): model = VLAN fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] help_texts = { - 'site': "The site at which this VLAN exists", + 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", 'vid': "Configured VLAN ID", 'name': "Configured VLAN name", @@ -498,7 +567,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 'role': "The primary function of this VLAN", } widgets = { - 'site': forms.Select(attrs={'filter-for': 'group'}), + 'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}), } def __init__(self, *args, **kwargs): @@ -511,11 +580,11 @@ class VLANForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) else: - self.fields['group'].choices = [] + self.fields['group'].queryset = VLANGroup.objects.filter(site=None) class VLANFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Site not found.'}) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'VLAN group not found.'}) @@ -565,14 +634,27 @@ def vlan_status_choices(): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') - group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', - null_option=(0, 'None')) - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', - null_option=(0, 'None')) + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('vlans')), + to_field_name='slug', + null_option=(0, 'Global') + ) + group_id = FilterChoiceField( + queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), + label='VLAN group', + null_option=(0, 'None') + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('vlans')), + to_field_name='slug', + null_option=(0, 'None') + ) status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) - role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', - null_option=(0, 'None')) + role = FilterChoiceField( + queryset=Role.objects.annotate(filter_count=Count('vlans')), + to_field_name='slug', + null_option=(0, 'None') + ) # diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index b29890723..8346169ce 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -1,4 +1,4 @@ -from django.db.models import Lookup +from django.db.models import Lookup, Transform, IntegerField from django.db.models.lookups import BuiltinLookup @@ -87,3 +87,12 @@ class NetHost(Lookup): rhs_params[0] = rhs_params[0].split('/')[0] params = lhs_params + rhs_params return 'HOST(%s) = %s' % (lhs, rhs), params + + +class NetMaskLength(Transform): + lookup_name = 'net_mask_length' + function = 'MASKLEN' + + @property + def output_field(self): + return IntegerField() diff --git a/netbox/ipam/migrations/0015_global_vlans.py b/netbox/ipam/migrations/0015_global_vlans.py new file mode 100644 index 000000000..18d82cbaf --- /dev/null +++ b/netbox/ipam/migrations/0015_global_vlans.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-21 18:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0014_ipaddress_status_add_deprecated'), + ] + + operations = [ + migrations.AlterField( + model_name='vlan', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'), + ), + migrations.AlterField( + model_name='vlangroup', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index d37fdec25..a04e4b9ce 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -485,7 +485,7 @@ class VLANGroup(models.Model): """ name = models.CharField(max_length=50) slug = models.SlugField() - site = models.ForeignKey('dcim.Site', related_name='vlan_groups') + site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) class Meta: ordering = ['site', 'name'] @@ -497,6 +497,8 @@ class VLANGroup(models.Model): verbose_name_plural = 'VLAN groups' def __str__(self): + if self.site is None: + return self.name return u'{} - {}'.format(self.site.name, self.name) def get_absolute_url(self): @@ -513,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it. """ - site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT) + site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ MinValueValidator(1), @@ -551,7 +553,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def to_csv(self): return csv_format([ - self.site.name, + self.site.name if self.site else None, self.group.name if self.group else None, self.vid, self.name, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f93f297e0..71e261dce 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -297,9 +297,17 @@ def aggregate(request, pk): prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/aggregate.html', { 'aggregate': aggregate, 'prefix_table': prefix_table, + 'permissions': permissions, }) @@ -385,7 +393,9 @@ class PrefixListView(ObjectListView): def prefix(request, pk): - prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'role'), pk=pk) + prefix = get_object_or_404(Prefix.objects.select_related( + 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' + ), pk=pk) try: aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) @@ -425,6 +435,13 @@ def prefix(request, pk): child_prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/prefix.html', { 'prefix': prefix, 'aggregate': aggregate, @@ -432,6 +449,7 @@ def prefix(request, pk): 'parent_prefix_table': parent_prefix_table, 'child_prefix_table': child_prefix_table, 'duplicate_prefix_table': duplicate_prefix_table, + 'permissions': permissions, 'return_url': prefix.get_absolute_url(), }) @@ -490,9 +508,17 @@ def prefix_ipaddresses(request, pk): ip_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_ipaddress'), + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + return render(request, 'ipam/prefix_ipaddresses.html', { 'prefix': prefix, 'ip_table': ip_table, + 'permissions': permissions, }) @@ -559,6 +585,8 @@ def ipaddress_assign(request, pk): device.save() return redirect('ipam:ipaddress', pk=ipaddress.pk) + else: + assert False, form.errors else: form = forms.IPAddressAssignForm() @@ -705,7 +733,7 @@ class VLANListView(ObjectListView): def vlan(request, pk): - vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk) + vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk) prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') prefix_table = tables.PrefixBriefTable(list(prefixes)) prefix_table.exclude = ('vlan',) @@ -764,9 +792,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.ServiceForm template_name = 'ipam/service_edit.html' - def alter_obj(self, obj, args, kwargs): - if 'device' in kwargs: - obj.device = get_object_or_404(Device, pk=kwargs['device']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'device' in url_kwargs: + obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj def get_return_url(self, obj): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7eb8e04a2..97cf27ec5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.8.4' +VERSION = '1.9.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -104,6 +104,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'debug_toolbar', 'django_tables2', + 'mptt', 'rest_framework', 'rest_framework_swagger', 'circuits', diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 4f569edea..11ea04b72 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -264,6 +264,15 @@ ul.rack_far_face li.blocked { #ffc7c7 14px ); } +ul.rack_near_face li.reserved { + background: repeating-linear-gradient( + 45deg, + #f7f7f7, + #f7f7f7 7px, + #c7c7ff 7px, + #c7c7ff 14px + ); +} ul.rack_near_face { z-index: 200; } diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 3a5ad2b83..e421f6283 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -68,37 +68,38 @@ $(document).ready(function() { }); // API select widget - $('select[filter-for]').change(function () { + $('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); + var child_selected = child_field.val(); - // Wipe out any existing options within the child field + // Wipe out any existing options within the child field and create a default option child_field.empty(); - child_field.append($("").attr("value", "").text("")); - - if ($(this).val()) { + child_field.append($("").attr("value", "").text("---------")); + if ($(this).val() || $(this).attr('nullable') == 'true') { 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); + // Determine the filter fields needed to make an API call + var filter_regex = /\{\{([a-z_]+)\}\}/g; + var match; + while (match = filter_regex.exec(api_url)) { + var filter_field = $('#id_' + match[1]); 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; + api_url = api_url.replace(match[0], filter_field.val()); + } else if ($(this).attr('nullable') == 'true') { + api_url = api_url.replace(match[0], '0'); } - - }); + } // If all URL variables have been replaced, make the API call if (api_url.search('{{') < 0) { + console.log(child_name + ": Fetching " + api_url); $.ajax({ url: api_url, dataType: 'json', @@ -106,7 +107,9 @@ $(document).ready(function() { $.each(response, function (index, choice) { var option = $("").attr("value", choice.id).text(choice[display_field]); if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { - option.attr("disabled", "disabled") + option.attr("disabled", "disabled"); + } else if (choice.id == child_selected) { + option.attr("selected", "selected"); } child_field.append(option); }); diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index af6f62fbd..5f59daad4 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -7,8 +7,8 @@ from dcim.models import Device class SecretFilter(django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) role_id = django_filters.ModelMultipleChoiceFilter( @@ -33,7 +33,9 @@ class SecretFilter(django_filters.FilterSet): model = Secret fields = ['name'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(device__name__icontains=value) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b4c64b485..2e08b0b22 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -101,7 +101,10 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm): class SecretFilterForm(BootstrapMixin, forms.Form): q = forms.CharField(required=False, label='Search') - role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') + role = FilterChoiceField( + queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), + to_field_name='slug' + ) # diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 4e63cf337..90bb3ad62 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -37,6 +37,11 @@
  • Import Sites
  • {% endif %}
  • +
  • Regions
  • + {% if perms.dcim.add_region %} +
  • Add a Region
  • + {% endif %} +
  • Tenants
  • {% if perms.tenancy.add_tenant %}
  • Add a Tenant
  • diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ab54b45a5..f311ccb73 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -66,6 +66,10 @@ Tenant {% if circuit.tenant %} + {% if circuit.tenant.group %} + {{ circuit.tenant.group.name }} + + {% endif %} {{ circuit.tenant }} {% else %} None diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index ba0f8b5fe..948ccfb9a 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -27,6 +27,10 @@ Site + {% if termination.site.region %} + {{ termination.site.region }} + + {% endif %} {{ termination.site }} @@ -34,7 +38,8 @@ Termination {% if termination.interface %} - {{ termination.interface.device }} {{ termination.interface }} + {{ termination.interface.device }} + {{ termination.interface }} {% else %} Not defined {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c9c8b9742..5465b8599 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -48,7 +48,7 @@

    {{ provider }}

    {% include 'inc/created_updated.html' with obj=provider %}
    -
    +
    Provider @@ -104,6 +104,12 @@ {% endif %} + + Circuits + + {{ provider.circuits.count }} + +
    {% with provider.get_custom_fields as custom_fields %} @@ -122,12 +128,20 @@
    -
    +
    Circuits
    + + + + + + + + {% for c in circuits %} + + + + {% empty %} diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html index c237bc2c9..f896ebbd5 100644 --- a/netbox/templates/dcim/consoleport_connect.html +++ b/netbox/templates/dcim/consoleport_connect.html @@ -7,6 +7,9 @@ {% block content %} {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ consoleport.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.console_server %}
    diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html index e747a9d57..08850d1ed 100644 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ consoleserverport.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.device %}
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 4507e2141..081397774 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -14,26 +14,28 @@ Device
    Circuit IDTypeTenantA SideZ SideDescription
    @@ -136,6 +150,34 @@ {{ c.type }} + {% if c.tenant %} + {{ c.tenant }} + {% else %} + + {% endif %} + + {% if c.termination_a %} + {{ c.termination_a.site }} + {% else %} + + {% endif %} + + {% if c.termination_z %} + {{ c.termination_z.site }} + {% else %} + + {% endif %} + + {% if c.description %} + {{ c.description }} + {% else %} + + {% endif %} +
    - - - - @@ -44,15 +46,29 @@ U{{ parent.position }} / {{ parent.get_face_display }} ({{ parent }} - {{ device.parent_bay.name }}) {% endwith %} - {% elif device.position %} + {% elif device.rack and device.position %} U{{ device.position }} / {{ device.get_face_display }} - {% elif device.device_type.u_height %} + {% elif device.rack and device.device_type.u_height %} Not racked {% else %} N/A {% endif %} + + + + - - + {% if device.device_type.interface_templates.exists %} + + + + {% endif %} {% endfor %} {% for cp in console_ports %} {% include 'dcim/inc/consoleport.html' %} {% empty %} - - - + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} {% endfor %} {% for pp in power_ports %} {% include 'dcim/inc/powerport.html' %} {% empty %} - - - + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} {% endfor %}
    Tenant - {% if device.tenant %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
    Site - {{ device.rack.site }} + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} + {{ device.site }}
    Rack - {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% if device.rack %} + {% if device.rack.group %} + {{ device.rack.group.name }} + + {% endif %} + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% else %} + None + {% endif %}
    Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group.name }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
    Device Type @@ -236,38 +252,44 @@ {% for iface in mgmt_interfaces %} {% include 'dcim/inc/interface.html' with icon='wrench' %} {% empty %} -
    - No management interfaces defined - {% if perms.dcim.add_interface %} - - {% endif %} -
    + No management interfaces defined + {% if perms.dcim.add_interface %} + + {% endif %} +
    - No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
    + No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
    - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} -
    + No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
    {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} @@ -314,7 +336,11 @@ {{ rd }} - Rack {{ rd.rack }} + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} {{ rd.device_type.full_name }} @@ -379,9 +405,10 @@ {% endif %} {% endif %} {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.delete_interface %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} {% csrf_token %} + {% endif %}
    diff --git a/netbox/templates/dcim/device_component_add.html b/netbox/templates/dcim/device_component_add.html index ab8f3bb21..91f39ab9b 100644 --- a/netbox/templates/dcim/device_component_add.html +++ b/netbox/templates/dcim/device_component_add.html @@ -3,7 +3,7 @@ {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} -{% block content %}{{ form.errors }} +{% block content %} {% csrf_token %}
    diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 74b453e1d..9f31c73fc 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -1,17 +1,17 @@
    - {% if device.rack %} -
    diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index bfb44b75d..86c2e4090 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -6,14 +6,20 @@ {% endif %} {{ iface.name }} + {% if iface.lag %} + {{ iface.lag.name }} + {% endif %} {% if iface.description %} {% endif %} + {% if iface.is_lag %} +
    {{ iface.member_interfaces.all|join:", "|default:"No members" }} + {% endif %} {{ iface.mac_address|default:'' }} - {% if not iface.is_physical %} + {% if iface.is_virtual %} Virtual interface {% elif iface.connection %} {% with iface.connected_interface as connected_iface %} @@ -48,7 +54,7 @@ {% endif %} {% endif %} {% if perms.dcim.change_interface %} - {% if iface.is_physical %} + {% if not iface.is_virtual %} {% if iface.connection %} {% if iface.connection.connection_status %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 0ffc6b7ad..049dcbc61 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,3 +1,5 @@ +{% load helpers %} +
      {% for u in rack.units %}
    • {{ u }}
    • @@ -35,9 +37,14 @@ {% endifequal %} {% else %} -
    • +
    • {% if perms.dcim.add_device %} - add device + add device {% endif %}
    • {% endif %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index ea30ad006..ad0335398 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -7,88 +7,88 @@ {% block content %}

      Connect Interfaces

      -{% csrf_token %} -
      -
      - {% if form.non_field_errors %} -
      -
      Errors
      + {% csrf_token %} +
      +
      + {% if form.non_field_errors %} +
      +
      Errors
      +
      + {{ form.non_field_errors }} +
      +
      + {% endif %} +
      +
      +
      +
      +
      +
      + A Side +
      - {{ form.non_field_errors }} -
      -
      - {% endif %} -
      -
      -
      -
      -
      -
      - A Side -
      -
      -
      - -
      -

      {{ device.rack.site }}

      +
      + +
      +

      {{ device.site }}

      +
      -
      -
      - -
      -

      {{ device.rack }}

      +
      + +
      +

      {{ device.rack|default:"None" }}

      +
      -
      -
      - -
      -

      {{ device }}

      +
      + +
      +

      {{ device }}

      +
      + {% render_field form.interface_a %}
      - {% render_field form.interface_a %}
      -
      -
      - -
      -
      -
      -
      - B Side -
      -
      - -
      - -
      - {% render_field form.site_b %} - {% render_field form.rack_b %} - {% render_field form.device_b %} -
      +
      + +
      +
      +
      +
      + B Side +
      +
      + +
      + +
      + {% render_field form.site_b %} + {% render_field form.rack_b %} + {% render_field form.device_b %} +
      +
      + {% render_field form.interface_b %}
      - {% render_field form.interface_b %}
      -
      -
      -
      -
      - {% render_field form.connection_status %}
      -
      -
      -
      - - - Cancel +
      +
      + {% render_field form.connection_status %} +
      +
      +
      +
      + + + Cancel +
      -
      {% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html index a302722df..927ed7b71 100644 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -6,7 +6,10 @@ {% block content %}
      -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
      {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
      +
      + +
      +

      {{ poweroutlet.device.site }}

      +
      +
      {% render_field form.rack %} {% render_field form.device %}
      diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html index 94e567e68..9e7f1fae9 100644 --- a/netbox/templates/dcim/powerport_connect.html +++ b/netbox/templates/dcim/powerport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
      {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
      +
      + +
      +

      {{ powerport.device.site }}

      +
      +
      {% render_field form.rack %} {% render_field form.pdu %}
      diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d73a0b560..d6529c2a4 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -64,6 +64,10 @@ Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} {{ rack.site }} @@ -91,6 +95,10 @@ Tenant {% if rack.tenant %} + {% if rack.tenant.group %} + {{ rack.tenant.group.name }} + + {% endif %} {{ rack.tenant }} {% else %} None @@ -189,6 +197,51 @@ {% endif %}
      +
      +
      + Reservations +
      + {% if reservations %} + + + + + + + {% for resv in reservations %} + + + + + + {% endfor %} +
      UnitsDescription
      {{ resv.units|join:', ' }} + {{ resv.description }}
      + {{ resv.user }} · {{ resv.created }} +
      + {% if perms.change_rackreservation %} + + + + {% endif %} + {% if perms.delete_rackreservation %} + + + + {% endif %} +
      + {% else %} +
      None
      + {% endif %} + {% if perms.dcim.add_rackreservation %} + + {% endif %} +
      diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html new file mode 100644 index 000000000..b54201a34 --- /dev/null +++ b/netbox/templates/dcim/region_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Regions{% endblock %} + +{% block content %} +
      + {% if perms.dcim.add_region %} + + + Add a region + + {% endif %} +
      +

      {{ block.title }}

      +
      +
      + {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %} +
      +
      +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 210ec0c82..bd7171700 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -9,7 +9,12 @@
      @@ -55,10 +60,28 @@ Site
      + + + + +
      Region + {% if site.region %} + {% for region in site.region.get_ancestors %} + {{ region }} + + {% endfor %} + {{ site.region }} + {% else %} + None + {% endif %} +
      Tenant {% if site.tenant %} + {% if site.tenant.group %} + {{ site.tenant.group.name }} + + {% endif %} {{ site.tenant }} {% else %} None @@ -85,6 +108,13 @@ {% endif %}
      +
      +
      +
      + Contact Info +
      + + + + + + @@ -71,7 +76,7 @@
      Physical Address diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index d1f211adb..98f16ad25 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -7,6 +7,7 @@
      {% render_field form.name %} {% render_field form.slug %} + {% render_field form.region %} {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html index 3018cc2f1..a7ac47ab5 100644 --- a/netbox/templates/dcim/site_import.html +++ b/netbox/templates/dcim/site_import.html @@ -38,6 +38,11 @@
      URL-friendly name ash4-south
      RegionName of region (optional)North America
      Tenant Name of tenant (optional)

      Example

      -
      ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
      +
      ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
      {% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4ad5dba05..9187b81b4 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -30,8 +30,16 @@ Tenant {% if prefix.tenant %} + {% if prefix.tenant.group %} + {{ prefix.tenant.group.name }} + + {% endif %} {{ prefix.tenant }} {% elif prefix.vrf.tenant %} + {% if prefix.vrf.tenant.group %} + {{ prefix.vrf.tenant.group.name }} + + {% endif %} {{ prefix.vrf.tenant }} {% else %} @@ -53,6 +61,10 @@ Site {% if prefix.site %} + {% if prefix.site.region %} + {{ prefix.site.region }} + + {% endif %} {{ prefix.site }} {% else %} None @@ -63,6 +75,10 @@ VLAN {% if prefix.vlan %} + {% if prefix.vlan.group %} + {{ prefix.vlan.group.name }} + + {% endif %} {{ prefix.vlan.display_name }} {% else %} None @@ -79,7 +95,7 @@ Role {% if prefix.role %} - {{ prefix.role }} + {{ prefix.role }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 3d81392e0..6c1fb07d2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -8,9 +8,11 @@
      @@ -53,7 +55,17 @@ - + @@ -77,6 +89,10 @@
      Site{{ vlan.site }} + {% if vlan.site %} + {% if vlan.site.region %} + {{ vlan.site.region }} + + {% endif %} + {{ vlan.site }} + {% else %} + None + {% endif %} +
      GroupTenant {% if vlan.tenant %} + {% if vlan.tenant.group %} + {{ vlan.tenant.group.name }} + + {% endif %} {{ vlan.tenant }} {% else %} None @@ -93,7 +109,7 @@ Role {% if vlan.role %} - {{ vlan.role }} + {{ vlan.role }} {% else %} None {% endif %} diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 6eeeb1e7b..ed1721102 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -8,8 +8,8 @@ from .models import Tenant, TenantGroup class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): - q = django_filters.MethodFilter( - action='search', + q = django_filters.CharFilter( + method='search', label='Search', ) group_id = NullableModelMultipleChoiceFilter( @@ -26,9 +26,11 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Tenant - fields = ['q', 'group_id', 'group', 'name'] + fields = ['name'] - def search(self, queryset, value): + def search(self, queryset, name, value): + if not value.strip(): + return queryset return queryset.filter( Q(name__icontains=value) | Q(description__icontains=value) | diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 0e29a8495..485f2f34b 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -56,5 +56,8 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant q = forms.CharField(required=False, label='Search') - group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), - to_field_name='slug', null_option=(0, 'None')) + group = FilterChoiceField( + queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), + to_field_name='slug', + null_option=(0, 'None') + ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f6e0b36b1..76ce1796c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -2,6 +2,8 @@ import csv import itertools import re +from mptt.forms import TreeNodeMultipleChoiceField + from django import forms from django.conf import settings from django.core.urlresolvers import reverse_lazy @@ -169,6 +171,27 @@ class SelectWithDisabled(forms.Select): force_text(option_label)) +class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): + """ + MultiSelect widgets for a SimpleArrayField. Choices must be populated on the widget. + """ + + def __init__(self, *args, **kwargs): + self.delimiter = kwargs.pop('delimiter', ',') + super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs) + + def render_options(self, selected_choices): + # Split the delimited string of values into a list + if selected_choices: + selected_choices = selected_choices.split(self.delimiter) + return super(ArrayFieldSelectMultiple, self).render_options(selected_choices) + + def value_from_datadict(self, data, files, name): + # Condense the list of selected choices into a delimited string + data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name) + return self.delimiter.join(data) + + class APISelect(SelectWithDisabled): """ A select widget populated via an API call @@ -344,7 +367,7 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceField(forms.ModelMultipleChoiceField): +class FilterChoiceFieldMixin(object): iterator = forms.models.ModelChoiceIterator def __init__(self, null_option=None, *args, **kwargs): @@ -353,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceField, self).__init__(*args, **kwargs) + super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) def label_from_instance(self, obj): + label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) if hasattr(obj, 'filter_count'): - return u'{} ({})'.format(obj, obj.filter_count) - return force_text(obj) + return u'{} ({})'.format(label, obj.filter_count) + return label def _get_choices(self): if hasattr(self, '_choices'): @@ -370,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): choices = property(_get_choices, forms.ChoiceField._set_choices) +class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): + pass + + +class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): + pass + + class LaxURLField(forms.URLField): """ Custom URLField which allows any valid URL scheme diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 74d490390..164aa24b2 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -27,6 +27,14 @@ def getlist(value, arg): return value.getlist(arg) +@register.filter +def getkey(value, key): + """ + Return a dictionary item specified by key + """ + return value[key] + + @register.filter(is_safe=True) def gfm(value): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 315298383..4d6ec3332 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -145,9 +145,9 @@ class ObjectEditView(View): return get_object_or_404(self.model, pk=kwargs['pk']) return self.model() - def alter_obj(self, obj, args, kwargs): + def alter_obj(self, obj, request, url_args, url_kwargs): # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined - # given some parameter from the request URI. + # given some parameter from the request URL. return obj def get_return_url(self, obj): @@ -159,7 +159,7 @@ class ObjectEditView(View): def get(self, request, *args, **kwargs): obj = self.get_object(kwargs) - obj = self.alter_obj(obj, args, kwargs) + obj = self.alter_obj(obj, request, args, kwargs) initial_data = {k: request.GET[k] for k in self.fields_initial if k in request.GET} form = self.form_class(instance=obj, initial=initial_data) @@ -173,7 +173,7 @@ class ObjectEditView(View): def post(self, request, *args, **kwargs): obj = self.get_object(kwargs) - obj = self.alter_obj(obj, args, kwargs) + obj = self.alter_obj(obj, request, args, kwargs) form = self.form_class(request.POST, instance=obj) if form.is_valid(): @@ -307,11 +307,12 @@ class BulkAddView(View): if form.is_valid(): # The first field will be used as the pattern - pattern_field = form.fields.keys()[0] + field_names = list(form.fields.keys()) + pattern_field = field_names[0] pattern = form.cleaned_data[pattern_field] # All other fields will be copied as object attributes - kwargs = {k: form.cleaned_data[k] for k in form.fields.keys()[1:]} + kwargs = {k: form.cleaned_data[k] for k in field_names[1:]} new_objs = [] try: @@ -470,7 +471,9 @@ class BulkEditView(View): return redirect(return_url) else: - form = self.form(self.cls, initial={'pk': pk_list}) + initial_data = request.POST.copy() + initial_data['pk'] = pk_list + form = self.form(self.cls, initial=initial_data) selected_objects = self.cls.objects.filter(pk__in=pk_list) if not selected_objects: diff --git a/requirements.txt b/requirements.txt index caa678f4c..2c6044f73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ cffi>=1.8 cryptography>=1.4 Django>=1.10 django-debug-toolbar>=1.6 -django-filter==0.15.3 +django-filter>=1.0.1 +django-mptt==0.8.7 django-rest-swagger==0.3.10 django-tables2>=1.2.5 djangorestframework>=3.5.0