diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 45587b461..09f0d5d93 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.10 + placeholder: v3.4.0 validations: required: true - type: dropdown @@ -25,6 +25,7 @@ body: - "3.8" - "3.9" - "3.10" + - "3.11" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index de69bc9e0..d94f9e9de 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.10 + placeholder: v3.4.0 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75f98fbc..1d9692194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] node-version: ['14.x'] services: redis: diff --git a/base_requirements.txt b/base_requirements.txt index cc8695d6c..3e4811ece 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -4,7 +4,7 @@ bleach # The Python web framework on which NetBox is built # https://github.com/django/django -Django<4.1 +Django<4.2 # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers @@ -68,7 +68,7 @@ drf-yasg[validation] # Django wrapper for Graphene (GraphQL support) # https://github.com/graphql-python/graphene-django -graphene_django<3.0 +graphene_django # WSGI HTTP server # https://gunicorn.org/ diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index e4eb4baff..9ff71758f 100644 --- a/docs/configuration/data-validation.md +++ b/docs/configuration/data-validation.md @@ -58,9 +58,11 @@ The following model fields support configurable choices: * `circuits.Circuit.status` * `dcim.Device.status` * `dcim.Location.status` +* `dcim.Module.status` * `dcim.PowerFeed.status` * `dcim.Rack.status` * `dcim.Site.status` +* `dcim.VirtualDeviceContext.status` * `extras.JournalEntry.kind` * `ipam.IPAddress.status` * `ipam.IPRange.status` @@ -68,6 +70,7 @@ The following model fields support configurable choices: * `ipam.VLAN.status` * `virtualization.Cluster.status` * `virtualization.VirtualMachine.status` +* `wireless.WirelessLAN.status` The following colors are supported: diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 614e90eac..4eb090554 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -141,6 +141,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## QUEUE_MAPPINGS + +Allows changing which queues are used internally for background tasks. + +```python +QUEUE_MAPPINGS = { + 'webhook': 'low', + 'report': 'high', + 'script': 'high', +} +``` + +If no queue is defined the queue named `default` will be used. + +--- + ## RELEASE_CHECK_URL Default: None (disabled) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index a62d14fef..15f743754 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 41f7cc71a..ae023b4d0 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -1,5 +1,13 @@ # Security & Authentication Parameters +## ALLOW_TOKEN_RETRIEVAL + +Default: True + +If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. + +--- + ## ALLOWED_URL_SCHEMES !!! tip "Dynamic Configuration Parameter" diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 93f8fa902..3756b6a83 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -157,6 +157,14 @@ The file path to the location where [custom scripts](../customization/custom-scr --- +## SEARCH_BACKEND + +Default: `'netbox.search.backends.CachedValueSearchBackend'` + +The dotted path to the desired search backend class. `CachedValueSearchBackend` is currently the only search backend provided in NetBox, however this setting can be used to enable a custom backend. + +--- + ## STORAGE_BACKEND Default: None (local storage) diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index c443fa9f6..81aaa5247 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * Text: Free-form text (intended for single-line use) * Long text: Free-form of any length; supports Markdown rendering * Integer: A whole number (positive or negative) +* Decimal: A fixed-precision decimal number (4 decimal places) * Boolean: True or false * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index e5d5a1ef5..456bcf472 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a ### Via the Web UI -Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. +Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object. ### Via the API @@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \ --data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}' ``` +Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time. + ### Via the CLI Scripts can be run on the CLI by invoking the management command: diff --git a/docs/customization/reports.md b/docs/customization/reports.md index ab2f1f81b..b83c4a177 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r ### Via the Web UI -Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. +Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object. ### Via the API @@ -152,6 +152,8 @@ Our example report above would be called as: POST /api/extras/reports/devices.DeviceConnectionsReport/run/ ``` +Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time. + ### Via the CLI Reports can be run on the CLI by invoking the management command: diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index f4d171f48..aef11d666 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em ## 10. Add the model to the navigation menu -Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. +Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`. ## 11. REST API components diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index ad8fe5024..b7fd5e1e5 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model. If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default. -## 8. Update the UI templates +## 8. Update the SearchIndex + +Where applicable, add the new field to the model's SearchIndex for inclusion in global search. + +## 9. Update the UI templates Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. -## 9. Create/extend test cases +## 10. Create/extend test cases Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including: @@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality. -## 10. Update the model's documentation +## 11. Update the model's documentation Each model has a dedicated page in the documentation, at `models//.md`. Update this file to include any relevant information about the new field. diff --git a/docs/development/models.md b/docs/development/models.md index 01070fa3d..af11617c8 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -40,6 +40,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [dcim.RackReservation](../models/dcim/rackreservation.md) * [dcim.Site](../models/dcim/site.md) * [dcim.VirtualChassis](../models/dcim/virtualchassis.md) +* [dcim.VirtualDeviceContext](../models/dcim/virtualdevicecontext.md) * [ipam.Aggregate](../models/ipam/aggregate.md) * [ipam.ASN](../models/ipam/asn.md) * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) diff --git a/docs/development/search.md b/docs/development/search.md new file mode 100644 index 000000000..02bcaa898 --- /dev/null +++ b/docs/development/search.md @@ -0,0 +1,37 @@ +# Search + +NetBox v3.4 introduced a new global search mechanism, which employs the `extras.CachedValue` model to store discrete field values from many models in a single table. + +## SearchIndex + +To enable search support for a model, declare and register a subclass of `netbox.search.SearchIndex` for it. Typically, this will be done within an app's `search.py` module. + +```python +from netbox.search import SearchIndex, register_search + +@register_search +class MyModelIndex(SearchIndex): + model = MyModel + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) +``` + +A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below. + +### Field Weight Guidance + +| Weight | Field Role | Examples | +|--------|--------------------------------------------------|----------------------------------------------------| +| 50 | Unique serialized attribute | Device.asset_tag | +| 60 | Unique serialized attribute (per related object) | Device.serial | +| 100 | Primary human identifier | Device.name, Circuit.cid, Cable.label | +| 110 | Slug | Site.slug | +| 200 | Secondary identifier | Provider.account, DeviceType.part_number | +| 300 | Highly unique descriptive attribute | CircuitTermination.xconnect_id, IPAddress.dns_name | +| 500 | Description | Site.description | +| 1000 | Custom field default | - | +| 2000 | Other discrete attribute | CircuitTermination.port_speed | +| 5000 | Comment field | Site.comments | diff --git a/docs/features/customization.md b/docs/features/customization.md index 813914ae2..abce4bcba 100644 --- a/docs/features/customization.md +++ b/docs/features/customization.md @@ -71,13 +71,13 @@ To learn more about this feature, check out the [export template documentation]( NetBox administrators can install custom Python scripts, known as _reports_, which run within NetBox and can be executed and analyzed within the NetBox UI. Reports are a great way to evaluate NetBox objects against a set of arbitrary rules. For example, you could write a report to check that every router has a loopback interface with an IP address assigned, or that every site has a minimum set of VLANs defined. -When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). +When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). They can be run immediately or scheduled to run at a future time. To learn more about this feature, check out the [documentation for reports](../customization/reports.md). ## Custom Scripts -Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. +Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. As with reports, they can be run via the UI, REST API, or CLI, and be scheduled to execute at a future time. The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model. diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md index bec3e56de..03f386977 100644 --- a/docs/features/devices-cabling.md +++ b/docs/features/devices-cabling.md @@ -65,6 +65,10 @@ Each device can have an operational status, functional role, and software platfo Sometimes it is necessary to model a set of physical devices as sharing a single management plane. Perhaps the most common example of such a scenario is stackable switches. These can be modeled as virtual chassis in NetBox, with one device acting as the chassis master and the rest as members. All components of member devices will appear on the master. +### Virtual Device Contexts + +A virtual device context (VDC) is a logical partition within a device. Each VDC operates autonomously but shares a common pool of resources. Each interface can be assigned to one or more VDCs on its device. + ## Module Types & Modules Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays. diff --git a/docs/features/journaling.md b/docs/features/journaling.md index ce126bf27..8aebdb446 100644 --- a/docs/features/journaling.md +++ b/docs/features/journaling.md @@ -1,5 +1,5 @@ # Journaling -All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. +All primary and organizational models in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/features/search.md b/docs/features/search.md new file mode 100644 index 000000000..07394af97 --- /dev/null +++ b/docs/features/search.md @@ -0,0 +1,27 @@ +# Search + +## Global Search + +NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy. + +When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance. + +Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models. + +## Saved Filters + +Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use. + +For example, suppose you often need to locate all planned devices of a certain type within a region. The applicable filters can be applied and then saved as custom named filter for reuse, such that + +``` +?status=planned&device_type_id=78®ion_id=12 +``` + +becomes + +``` +?filter=my-custom-filter +``` + +These saved filters can be used both within the UI and for API queries. diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md index bb0e8e17f..9a2386d71 100644 --- a/docs/getting-started/populating-data.md +++ b/docs/getting-started/populating-data.md @@ -20,12 +20,14 @@ To create a new object in NetBox, find the object type in the navigation menu an ## Bulk Import (CSV/YAML) -NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. +NetBox supports the bulk import of new objects, and updating of existing objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.) +If an "id" field is added the data will be used to update existing records instead of importing new objects. + Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. ## Scripting diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index a6aa27b1b..583a4f3e9 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 10 or later required" - NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 11 or later required" + NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -35,7 +35,7 @@ sudo systemctl start postgresql sudo systemctl enable postgresql ``` -Before continuing, verify that you have installed PostgreSQL 10 or later: +Before continuing, verify that you have installed PostgreSQL 11 or later: ```no-highlight psql -V diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index a2a2d9df3..68a582e7f 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.8 or later required" - NetBox requires Python 3.8, 3.9, or 3.10. + NetBox requires Python 3.8, 3.9, 3.10 or 3.11. === "Ubuntu" diff --git a/docs/installation/index.md b/docs/installation/index.md index 8b588fccd..49163550d 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 802c13e49..cc49cd30e 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | ## 3. Install the Latest Release @@ -28,16 +28,15 @@ NetBox v3.0 and later require the following: As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. !!! warning - Use the same method as you used to install Netbox originally + Use the same method as you used to install NetBox originally -If you are not sure how Netbox was installed originally, check with this -command: +If you are not sure how NetBox was installed originally, check with this command: ``` ls -ld /opt/netbox /opt/netbox/.git ``` -If Netbox was installed from a release package, then `/opt/netbox` will be a +If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories. diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 4fc6d2dd8..a078bb82a 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type: For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices. -For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/). +For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/). ## Filtering @@ -56,6 +56,47 @@ The GraphQL API employs the same filtering logic as the UI and REST API. Filters ``` {"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"} ``` +In addition, filtering can be done on list of related objects as shown in the following query: + +``` +{ + device_list { + id + name + interfaces(enabled: true) { + name + } + } +} +``` + +## Multiple Return Types + +Certain queries can return multiple types of objects, for example cable terminations can return circuit terminations, console ports and many others. These can be queried using [inline fragments](https://graphql.org/learn/schema/#union-types) as shown below: + +``` +{ + cable_list { + id + a_terminations { + ... on CircuitTerminationType { + id + class_type + } + ... on ConsolePortType { + id + class_type + } + ... on ConsoleServerPortType { + id + class_type + } + } + } +} + +``` +The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort". ## Authentication diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 3a5aed055..6f54a8cb0 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -579,6 +579,9 @@ By default, a token can be used to perform all actions via the API that a user w Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +!!! warning "Restricting Token Retrieval" + The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. + #### Client IP Restriction !!! note diff --git a/docs/introduction.md b/docs/introduction.md index cffcb37dd..fe82e68aa 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -74,6 +74,6 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 10+ | +| Database | PostgreSQL 11+ | | Task queuing | Redis/django-rq | | Live device access | NAPALM (optional) | diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 050f93244..6dc4aa13e 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). +### Weight + +The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds). + ### Front & Rear Images Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index c90430faa..060c2b094 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed. The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module. +### Status + +The module's operational status. + +!!! tip + Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + ### Serial Number The unique physical serial number assigned to this module by its manufacturer. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index b8ec0ac6e..3122d2e00 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu ### Part Number An alternative part number to uniquely identify the module type. + +### Weight + +The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound). diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 57e7bec98..b603e7a0b 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -65,6 +65,18 @@ The height of the rack, measured in units. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +### Mounting Depth + +The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) + +### Weight + +The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). + +### Maximum Weight + +The maximum total weight capacity for all installed devices, inclusive of the rack itself. + ### Descending Units -If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) +If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.) diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md index c74c209e1..2e35ab11f 100644 --- a/docs/models/dcim/site.md +++ b/docs/models/dcim/site.md @@ -33,7 +33,7 @@ Each site can have multiple [AS numbers](../ipam/asn.md) assigned to it. ### Time Zone -The site's local time zone. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) +The site's local time zone. (Time zones are provided by the [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) library.) ### Physical Address diff --git a/docs/models/dcim/virtualdevicecontext.md b/docs/models/dcim/virtualdevicecontext.md new file mode 100644 index 000000000..fe2e61356 --- /dev/null +++ b/docs/models/dcim/virtualdevicecontext.md @@ -0,0 +1,33 @@ +# Virtual Device Context + +A virtual device context (VDC) represents a logical partition within a physical device, to which interfaces from the parent device can be allocated. Each VDC effectively provides an isolated control plane, but relies on shared resources of the parent device. A VDC is somewhat similar to a virtual machine in that it effects isolation between various components, but stops short of delivering a fully virtualized environment. + +Each VDC must be assigned to a device upon creation, after which interfaces belonging to that device can be assigned to one or more of its VDCs. A VDC can have any number of interfaces assigned to it, and an interface can belong to any number of VDCs. + +!!! info "A VDC by Any Other Name" + Network vendors use differing names for this concept. Cisco uses the term VDC, whereas Juniper refers to it as a _Virtual Routing Instance_, and Fortinet uses _Virtual Domain_, for instance. While there may be some nuance among the vendors' unique implementations, the general concept remains the same for each. + +## Fields + +### Device + +The device to which this VDC belongs. + +### Name + +The VDC's configured name. Must be unique to the assigned device. + +### Status + +The operational status of the VDC. + +### Identifier + +A vendor-prescribed unique identifier for the VDC (optional). Must be unique to the assigned device if defined. + +### Primary IPv4 & IPv6 Addresses + +Each VDC may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. + +!!! tip + NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md new file mode 100644 index 000000000..fd7922b7e --- /dev/null +++ b/docs/models/extras/branch.md @@ -0,0 +1,13 @@ +# Branches + +A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes. + +## Fields + +### Name + +The branch's name. + +### User + +The user to which the branch belongs (optional). diff --git a/docs/models/extras/savedfilter.md b/docs/models/extras/savedfilter.md new file mode 100644 index 000000000..68278403e --- /dev/null +++ b/docs/models/extras/savedfilter.md @@ -0,0 +1,47 @@ +# Saved Filters + +When filtering lists of objects in NetBox, users can save applied filters for future use. This is handy for complex filter strategies involving multiple discrete filters. For example, you might want to find all planned devices within a region that have a specific platform. Once you've applied the desired filters to the object list, simply create a saved filter with name and optional description. This filter can then be applied directly for future queries via both the UI and REST API. + +## Fields + +### Name + +The filter's human-friendly name. + +### Slug + +The unique identifier by which this filter will be referenced during application (e.g. `?filter=my-slug`). + +### User + +The user to which this filter belongs. The current user will be assigned automatically when creating saved filters via the UI, and cannot be changed. + +### Weight + +A numeric weight used to override alphabetic ordering of filters by name. Saved filters with a lower weight will be listed before those with a higher weight. + +### Enabled + +Determines whether this filter can be used. Disabled filters will not appear as options in the UI, however they will be included in API results. + +### Shared + +Determines whether this filter is intended for use by all users or only its owner. Note that disabling this field does **not** hide the filter from other users; it is merely excluded from the list of available filters in UI object list views. + +### Parameters + +The query parameters to apply when the filter is active. These must be specified as JSON data. For example, the URL query string + +``` +?status=active®ion_id=51&tag=alpha&tag=bravo +``` + +is represented in JSON as + +```json +{ + 'tag': ['alpha', 'bravo'], + 'status': 'active', + 'region_id': 51 +} +``` diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md new file mode 100644 index 000000000..feda2fee6 --- /dev/null +++ b/docs/models/extras/stagedchange.md @@ -0,0 +1,26 @@ +# Staged Changes + +A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md). + +Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method. + +## Fields + +!!! warning + Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager. + +### Branch + +The [branch](./branch.md) to which this change belongs. + +### Action + +The type of action this change represents: `create`, `update`, or `delete`. + +### Object + +A generic foreign key referencing the existing object to which this change applies. + +### Data + +JSON representation of the changes being made to the object (not applicable for deletions). diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md index 4da390310..de09fee29 100644 --- a/docs/models/ipam/fhrpgroup.md +++ b/docs/models/ipam/fhrpgroup.md @@ -19,6 +19,10 @@ The wire protocol employed by cooperating servers to maintain the virtual [IP ad The group's numeric identifier. +### Name + +An optional name for the FHRP group. + ### Authentication Type The type of authentication employed by group nodes, if any. diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 5bb3dbd65..0f50fa75f 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -12,6 +12,13 @@ The service set identifier (SSID) for the wireless network. The [wireless LAN group](./wirelesslangroup.md) to which this wireless LAN is assigned (if any). +### Status + +The operational status of the wireless network. + +!!! tip + Additional statuses may be defined by setting `WirelessLAN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + ### VLAN Each wireless LAN can optionally be mapped to a [VLAN](../ipam/vlan.md), to model a bridge between wired and wireless segments. diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index dee0d3796..d819b76cb 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -4,11 +4,11 @@ NetBox provides several base form classes for use by plugins. -| Form Class | Purpose | -|---------------------------|--------------------------------------| -| `NetBoxModelForm` | Create/edit individual objects | -| `NetBoxModelCSVForm` | Bulk import objects from CSV data | -| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | +| Form Class | Purpose | +|----------------------------|--------------------------------------| +| `NetBoxModelForm` | Create/edit individual objects | +| `NetBoxModelImportForm` | Bulk import objects from CSV data | +| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | | `NetBoxModelFilterSetForm` | Filter objects within a list view | ### `NetBoxModelForm` @@ -45,19 +45,20 @@ class MyModelForm(NetBoxModelForm): !!! tip "Comment fields" If your form has a `comments` field, there's no need to list it; this will always appear last on the page. -### `NetBoxModelCSVForm` +### `NetBoxModelImportForm` -This form facilitates the bulk import of new objects from CSV data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. +This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. **Example** ```python from dcim.models import Site -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelChoiceField from .models import MyModel -class MyModelCSVForm(NetBoxModelCSVForm): + +class MyModelImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -69,6 +70,9 @@ class MyModelCSVForm(NetBoxModelCSVForm): fields = ('name', 'status', 'site', 'comments') ``` +!!! note "Previously NetBoxModelCSVForm" + This form class was previously named `NetBoxModelCSVForm`. It was renamed in NetBox v3.4 to convey support for JSON and YAML formats in addition to CSV. The `NetBoxModelCSVForm` class has been retained for backward compatibility and functions exactly the same as `NetBoxModelImportForm`. However, plugin authors should be aware that this backward compatability will be removed in NetBox v3.5. + ### `NetBoxModelBulkEditForm` This form facilitates editing multiple objects in bulk. Unlike a model form, this form does not have a child `Meta` class, and must explicitly define each field. All fields in a bulk edit form are generally declared with `required=False`. @@ -84,11 +88,12 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi ```python from django import forms from dcim.models import Site -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from utilities.forms import CommentField, DynamicModelChoiceField from .models import MyModel, MyModelStatusChoices -class MyModelEditForm(NetBoxModelCSVForm): + +class MyModelEditForm(NetBoxModelImportForm): name = forms.CharField( required=False ) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index a2f3b8ce9..dcbad9d8d 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -14,6 +14,7 @@ Plugins can do a lot, including: * Provide their own "pages" (views) in the web user interface * Inject template content and navigation links * Extend NetBox's REST and GraphQL APIs +* Load additional Django apps * Add custom request/response middleware However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. @@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig): default_settings = { 'baz': True } + django_apps = ["foo", "bar", "baz"] config = FooBarConfig ``` @@ -101,10 +103,12 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `default_settings` | A dictionary of configuration parameters and their default values | +| `django_apps` | A list of additional Django apps to load alongside the plugin | | `min_version` | Minimum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | | `queues` | A list of custom background task queues to create | +| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) | | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | | `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | | `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) | @@ -113,13 +117,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. !!! tip "Accessing Config Parameters" - Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example: + Plugin configuration parameters can be accessed using the `get_plugin_config()` function. For example: ```python - from django.conf import settings - settings.PLUGINS_CONFIG['myplugin']['verbose_name'] + from extras.plugins import get_plugin_config + get_plugin_config('my_plugin', 'verbose_name') ``` +#### Important Notes About `django_apps` + +Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration. + +Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. + +Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. + ## Create setup.py `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index c58621b81..b3bcb292a 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,23 +49,11 @@ class MyModel(NetBoxModel): ... ``` -### The `clone()` Method +### NetBoxModel Properties -!!! info - This method was introduced in NetBox v3.3. +#### `docs_url` -The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. - -Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: - -```python -class MyModel(NetBoxModel): - - def clone(self): - attrs = super().clone() - attrs['extra-value'] = 123 - return attrs -``` +This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models///`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/). ### Enabling Features Individually @@ -116,6 +104,8 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.ChangeLoggingMixin +::: netbox.models.features.CloningMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 52ae953a7..63402c747 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -1,25 +1,67 @@ # Navigation +## Menus + +!!! note + This feature was introduced in NetBox v3.4. + +A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below. + +```python title="navigation.py" +from extras.plugins import PluginMenu + +menu = PluginMenu( + label='My Plugin', + groups=( + ('Foo', (item1, item2, item3)), + ('Bar', (item4, item5)), + ), + icon_class='mdi mdi-router' +) +``` + +Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items. + +!!! tip + The path to the menu class can be modified by setting `menu` in the PluginConfig instance. + +A `PluginMenu` has the following attributes: + +| Attribute | Required | Description | +|--------------|----------|---------------------------------------------------| +| `label` | Yes | The text displayed as the menu heading | +| `groups` | Yes | An iterable of named groups containing menu items | +| `icon_class` | - | The CSS name of the icon to use for the heading | + +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) + +### The Default Menu + +If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu. + +```python title="navigation.py" +menu_items = (item1, item2, item3) +``` + +!!! tip + The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance. + ## Menu Items -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. +Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. -!!! tip - The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance. - -```python +```python filename="navigation.py" from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), +item1 = PluginMenuItem( + link='plugins:myplugin:myview', + link_text='Some text', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) ) ``` @@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes: ## Menu Buttons +Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects. + A `PluginMenuButton` has the following attributes: | Attribute | Required | Description | |---------------|----------|--------------------------------------------------------------------| | `link` | Yes | Name of the URL path to which this button links | | `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | -| `icon_class` | Yes | Button icon CSS class* | +| `icon_class` | Yes | Button icon CSS class | | `color` | - | One of the choices provided by `ButtonColorChoices` | | `permissions` | - | A list of permissions required to display this button | -*NetBox supports [Material Design Icons](https://materialdesignicons.com/). +Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md new file mode 100644 index 000000000..b6f24f58d --- /dev/null +++ b/docs/plugins/development/search.md @@ -0,0 +1,31 @@ +# Search + +!!! note + This feature was introduced in NetBox v3.4. + +Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below). + +```python +# search.py +from netbox.search import SearchIndex +from .models import MyModel + +class MyModelIndex(SearchIndex): + model = MyModel + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) +``` + +To register one or more indexes with NetBox, define a list named `indexes` at the end of this file: + +```python +indexes = [MyModelIndex] +``` + +!!! tip + The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance. + +::: netbox.search.SearchIndex diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md new file mode 100644 index 000000000..7a4446eea --- /dev/null +++ b/docs/plugins/development/staged-changes.md @@ -0,0 +1,42 @@ +# Staged Changes + +!!! danger "Experimental Feature" + This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time. + +!!! note + This feature was introduced in NetBox v3.4. + +NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example. + +To begin staging changes, first create a [branch](../../models/extras/branch.md): + +```python +from extras.models import Branch + +branch1 = Branch.objects.create(name='branch1') +``` + +Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction. + +```python +from extras.models import Branch +from netbox.staging import checkout + +branch1 = Branch.objects.get(name='branch1') +with checkout(branch1): + Site.objects.create(name='New Site', slug='new-site') + # ... +``` + +Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch. + +To apply the changes within a branch, call the branch's `commit()` method: + +```python +from extras.models import Branch + +branch1 = Branch.objects.get(name='branch1') +branch1.commit() +``` + +Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused). diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 2afe332c5..7f8a64744 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -82,23 +82,25 @@ class ThingEditView(ObjectEditView): Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. ::: netbox.views.generic.base.BaseObjectView + options: + members: + - get_queryset + - get_object + - get_extra_context ::: netbox.views.generic.ObjectView options: members: - - get_object - get_template_name ::: netbox.views.generic.ObjectEditView options: members: - - get_object - alter_object ::: netbox.views.generic.ObjectDeleteView options: - members: - - get_object + members: false ::: netbox.views.generic.ObjectChildrenView options: @@ -111,6 +113,10 @@ Below are the class definitions for NetBox's object views. These views handle CR Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. ::: netbox.views.generic.base.BaseMultiObjectView + options: + members: + - get_queryset + - get_extra_context ::: netbox.views.generic.ObjectListView options: @@ -121,7 +127,8 @@ Below are the class definitions for NetBox's multi-object views. These views han ::: netbox.views.generic.BulkImportView options: - members: false + members: + - save_object ::: netbox.views.generic.BulkEditView options: @@ -148,18 +155,51 @@ These views are provided to enable or enhance certain NetBox model features, suc ## Extending Core Views -Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: +### Additional Tabs -* `left_page()` - Inject content on the left side of the page -* `right_page()` - Inject content on the right side of the page -* `full_width_page()` - Inject content across the entire bottom of the page -* `buttons()` - Add buttons to the top of the page +!!! note + This feature was introduced in NetBox v3.4. + +Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`: + +```python +from dcim.models import Site +from myplugin.models import Stuff +from netbox.views import generic +from utilities.views import ViewTab, register_model_view + +@register_model_view(Site, name='myview', path='some-other-stuff') +class MyView(generic.ObjectView): + ... + tab = ViewTab( + label='Other Stuff', + badge=lambda obj: Stuff.objects.filter(site=obj).count(), + permission='myplugin.view_stuff' + ) +``` + +::: utilities.views.register_model_view + +::: utilities.views.ViewTab + +### Extra Template Content + +Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired method(s) to render custom content. Five methods are available: + +| Method | View | Description | +|---------------------|-------------|-----------------------------------------------------| +| `left_page()` | Object view | Inject content on the left side of the page | +| `right_page()` | Object view | Inject content on the right side of the page | +| `full_width_page()` | Object view | Inject content across the entire bottom of the page | +| `buttons()` | Object view | Add buttons to the top of the page | +| `list_buttons()` | List view | Add buttons to the top of the page | Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: -* `object` - The object being viewed +* `object` - The object being viewed (object views only) +* `model` - The model of the list view (list views only) * `request` - The current request * `settings` - Global NetBox settings * `config` - Plugin-specific configuration parameters diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md new file mode 100644 index 000000000..0527fee3f --- /dev/null +++ b/docs/release-notes/version-3.4.md @@ -0,0 +1,225 @@ +# NetBox v3.4 + +## v3.4.0 (2022-12-14) + +!!! warning "PostgreSQL 11 Required" + NetBox v3.4 requires PostgreSQL 11 or later. + +### Breaking Changes + +* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" within the same site will raise a validation error. +* The `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the ASN and contact models introduced in NetBox v3.1 prior to upgrading. +* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object. +* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead. +* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5. + +### New Features + +#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560)) + +NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine. + +#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854)) + +A new model representing virtual device contexts (VDCs) has been added. VDCs are logical partitions of resources within a device that can be managed independently. A VDC is created within a device and may have device interfaces assigned to it. An interface can be allocated to any number of VDCs on its device. + +#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623)) + +Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string + +``` +?status=active®ion_id=12&tenant=acme +``` + +can be saved and applied to future queries as + +``` +?filter=my-custom-filter +``` + +Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches. + +#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347)) + +NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user. + +#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961)) + +NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects. + +#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366)) + +Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours). + +#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851)) + +This release introduces a new programmatic API that enables plugins and custom scripts to prepare changes in NetBox without actually committing them to the active database. To stage changes, create and activate a branch using the `checkout()` context manager. Any changes made within this context will be captured, recorded, and rolled back for future use. Once ready, a branch can be applied to the active database by calling `merge()`. + +!!! danger "Experimental Feature" + This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time. + +### Enhancements + +* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits +* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search +* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import +* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects +* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types +* [#8485](https://github.com/netbox-community/netbox/issues/8485) - Enable journaling for all organizational models +* [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens +* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive +* [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects +* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types +* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations +* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model +* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups +* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type +* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules +* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models +* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types +* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks +* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns +* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN +* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types +* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts +* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization + +### Bug Fixes (from v3.4-beta1) + +* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned +* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs +* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs +* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables +* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table +* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects +* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL +* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words +* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data +* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search +* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search +* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API + +### Plugins API + +* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method +* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex` +* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu +* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models +* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps via `django_apps` attribute +* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation +* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin +* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function +* [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views + +### Other Changes + +* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model +* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model +* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data +* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 +* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk +* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app +* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function +* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 +* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views +* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo +* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization + +### REST API Changes + +* Added the `/api/dcim/virtual-device-contexts/` endpoint +* circuits.provider + * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields + * Added a `description` field +* dcim.Cable + * Added `description` and `comments` fields +* dcim.Device + * Added a `description` field +* dcim.DeviceType + * Added `description`, `weight`, and `weight_unit` fields +* dcim.Module + * Added a `description` field +* dcim.Interface + * Added the `vdcs` field +* dcim.Module + * Added a required `status` field +* dcim.ModuleType + * Added `description`, `weight`, and `weight_unit` fields +* dcim.PowerFeed + * Added a `description` field +* dcim.PowerPanel + * Added `description` and `comments` fields +* dcim.Rack + * Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields +* dcim.RackReservation + * Added a `comments` field +* dcim.VirtualChassis + * Added `description` and `comments` fields +* extras.CustomField + * Added a `search_weight` field +* extras.CustomLink + * Renamed `content_type` field to `content_types` +* extras.ExportTemplate + * Renamed `content_type` field to `content_types` +* extras.JobResult + * Added `interval`, `scheduled`, and `started` fields +* ipam.Aggregate + * Added a `comments` field +* ipam.ASN + * Added a `comments` field +* ipam.FHRPGroup + * Added `name` and `comments` fields +* ipam.IPAddress + * Added a `comments` field +* ipam.IPRange + * Added a `comments` field +* ipam.L2VPN + * Added a `comments` field +* ipam.Prefix + * Added a `comments` field +* ipam.RouteTarget + * Added a `comments` field +* ipam.Service + * Added a `comments` field +* ipam.ServiceTemplate + * Added a `comments` field +* ipam.VLAN + * Added a `comments` field +* ipam.VRF + * Added a `comments` field +* tenancy.Contact + * Added a `description` field +* virtualization.Cluster + * Added a `description` field +* virtualization.VirtualMachine + * Added a `description` field +* wireless.WirelessLAN + * Added a required `status` choice field + * Added a `comments` field +* wireless.WirelessLink + * Added a `comments` field + +### GraphQL API Changes + +* All object types now include a `display` field +* All cabled object types now include a `link_peers` field +* Add a `contacts` relationship for all relevant models +* dcim.Cable + * Add A/B terminations fields +* dcim.CableTermination + * Add `termination` field +* dcim.InventoryItem + * Add `component` field +* dcim.InventoryItemTemplate + * Add `component` field +* dcim.Rack + * Add `mounting_depth` field +* ipam.FHRPGroupAssignment + * Add `interface` field +* ipam.IPAddress + * Add `assigned_object` field +* ipam.L2VPNTermination + * Add `assigned_object` field +* ipam.VLANGroupType + * Add `scope` field diff --git a/mkdocs.yml b/mkdocs.yml index 58617cbad..2317dad6d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - Virtualization: 'features/virtualization.md' - Tenancy: 'features/tenancy.md' - Contacts: 'features/contacts.md' + - Search: 'features/search.md' - Context Data: 'features/context-data.md' - Change Logging: 'features/change-logging.md' - Journaling: 'features/journaling.md' @@ -128,9 +129,11 @@ nav: - Tables: 'plugins/development/tables.md' - Forms: 'plugins/development/forms.md' - Filters & Filter Sets: 'plugins/development/filtersets.md' + - Search: 'plugins/development/search.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' - Administration: - Authentication: @@ -189,13 +192,17 @@ nav: - Site: 'models/dcim/site.md' - SiteGroup: 'models/dcim/sitegroup.md' - VirtualChassis: 'models/dcim/virtualchassis.md' + - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: + - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - CustomField: 'models/extras/customfield.md' - CustomLink: 'models/extras/customlink.md' - ExportTemplate: 'models/extras/exporttemplate.md' - ImageAttachment: 'models/extras/imageattachment.md' - JournalEntry: 'models/extras/journalentry.md' + - SavedFilter: 'models/extras/savedfilter.md' + - StagedChange: 'models/extras/stagedchange.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' - IPAM: @@ -244,6 +251,7 @@ nav: - Adding Models: 'development/adding-models.md' - Extending Models: 'development/extending-models.md' - Signals: 'development/signals.md' + - Search: 'development/search.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' - Web UI: 'development/web-ui.md' @@ -251,6 +259,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.4: 'release-notes/version-3.4.md' - Version 3.3: 'release-notes/version-3.3.md' - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index c1d856f39..2bcb0895a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,8 +31,8 @@ class ProviderSerializer(NetBoxModelSerializer): class Meta: model = Provider fields = [ - 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'account', 'description', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index bc0b7d87d..3acf3b98c 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig): verbose_name = "Circuits" def ready(self): - import circuits.signals + from . import signals, search diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index cee38fb18..8e4c9ab06 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.db.models import Q +from django.utils.translation import gettext as _ from dcim.filtersets import CabledObjectFilterSet from dcim.models import Region, Site, SiteGroup @@ -24,48 +25,48 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=Region.objects.all(), field_name='circuits__terminations__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='circuits__terminations__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='circuits__terminations__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='circuits__terminations__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), - label='Site', + label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), - label='ASN (ID)', + label=_('ASN (ID)'), ) class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account'] + fields = ['id', 'name', 'slug', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -73,8 +74,6 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | - Q(noc_contact__icontains=value) | - Q(admin_contact__icontains=value) | Q(comments__icontains=value) ) @@ -82,13 +81,13 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class ProviderNetworkFilterSet(NetBoxModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), - label='Provider (ID)', + label=_('Provider (ID)'), ) provider = django_filters.ModelMultipleChoiceFilter( field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', - label='Provider (slug)', + label=_('Provider (slug)'), ) class Meta: @@ -116,28 +115,28 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), - label='Provider (ID)', + label=_('Provider (ID)'), ) provider = django_filters.ModelMultipleChoiceFilter( field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', - label='Provider (slug)', + label=_('Provider (slug)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), - label='ProviderNetwork (ID)', + label=_('ProviderNetwork (ID)'), ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), - label='Circuit type (ID)', + label=_('Circuit type (ID)'), ) type = django_filters.ModelMultipleChoiceFilter( field_name='type__slug', queryset=CircuitType.objects.all(), to_field_name='slug', - label='Circuit type (slug)', + label=_('Circuit type (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=CircuitStatusChoices, @@ -147,38 +146,38 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=Region.objects.all(), field_name='terminations__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='terminations__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='terminations__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='terminations__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) class Meta: @@ -201,25 +200,25 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) circuit_id = django_filters.ModelMultipleChoiceFilter( queryset=Circuit.objects.all(), - label='Circuit', + label=_('Circuit'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( queryset=ProviderNetwork.objects.all(), - label='ProviderNetwork (ID)', + label=_('ProviderNetwork (ID)'), ) class Meta: diff --git a/netbox/circuits/forms/__init__.py b/netbox/circuits/forms/__init__.py index 5c23f833a..1499f98b2 100644 --- a/netbox/circuits/forms/__init__.py +++ b/netbox/circuits/forms/__init__.py @@ -1,4 +1,4 @@ from .bulk_edit import * from .bulk_import import * from .filtersets import * -from .models import * +from .model_forms import * diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index b6ba42afb..e1fe6338d 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -20,10 +20,6 @@ __all__ = ( class ProviderBulkEditForm(NetBoxModelBulkEditForm): - asn = forms.IntegerField( - required=False, - label='ASN (legacy)' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -32,33 +28,23 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): account = forms.CharField( max_length=30, required=False, - label='Account number' + label=_('Account number') ) - portal_url = forms.URLField( - required=False, - label='Portal' - ) - noc_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='NOC contact' - ) - admin_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='Admin contact' + description = forms.CharField( + max_length=200, + required=False ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = Provider fieldsets = ( - (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), + (None, ('asns', 'account', )), ) nullable_fields = ( - 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'asns', 'account', 'description', 'comments', ) @@ -70,7 +56,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): service_id = forms.CharField( max_length=100, required=False, - label='Service ID' + label=_('Service ID') ) description = forms.CharField( max_length=200, @@ -78,7 +64,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = ProviderNetwork @@ -132,7 +118,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) commit_rate = forms.IntegerField( required=False, - label='Commit rate (Kbps)' + label=_('Commit rate (Kbps)') ) description = forms.CharField( max_length=100, @@ -140,7 +126,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = Circuit diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index cc2d0409a..b61fb1bc7 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,77 +1,102 @@ +from django import forms + from circuits.choices import CircuitStatusChoices from circuits.models import * -from netbox.forms import NetBoxModelCSVForm +from dcim.models import Site +from django.utils.translation import gettext as _ +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( - 'CircuitCSVForm', - 'CircuitTypeCSVForm', - 'ProviderCSVForm', - 'ProviderNetworkCSVForm', + 'CircuitImportForm', + 'CircuitTerminationImportForm', + 'CircuitTypeImportForm', + 'ProviderImportForm', + 'ProviderNetworkImportForm', ) -class ProviderCSVForm(NetBoxModelCSVForm): +class ProviderImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = Provider fields = ( - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'name', 'slug', 'account', 'description', 'comments', 'tags', ) -class ProviderNetworkCSVForm(NetBoxModelCSVForm): +class ProviderNetworkImportForm(NetBoxModelImportForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Assigned provider' + help_text=_('Assigned provider') ) class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'service_id', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags' ] -class CircuitTypeCSVForm(NetBoxModelCSVForm): +class CircuitTypeImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = CircuitType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') help_texts = { - 'name': 'Name of circuit type', + 'name': _('Name of circuit type'), } -class CircuitCSVForm(NetBoxModelCSVForm): +class CircuitImportForm(NetBoxModelImportForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Assigned provider' + help_text=_('Assigned provider') ) type = CSVModelChoiceField( queryset=CircuitType.objects.all(), to_field_name='name', - help_text='Type of circuit' + help_text=_('Type of circuit') ) status = CSVChoiceField( choices=CircuitStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = Circuit fields = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', - 'description', 'comments', + 'description', 'comments', 'tags' + ] + + +class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False + ) + provider_network = CSVModelChoiceField( + queryset=ProviderNetwork.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = CircuitTermination + fields = [ + 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'pp_info', 'description', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 29410ffdf..d7cfc494d 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -20,7 +20,7 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( @@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/model_forms.py similarity index 77% rename from netbox/circuits/forms/models.py rename to netbox/circuits/forms/model_forms.py index 7dd85aef9..cd73780fa 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/model_forms.py @@ -1,4 +1,3 @@ -from django import forms from django.utils.translation import gettext as _ from circuits.models import * @@ -7,8 +6,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( - BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect, + CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField, + StaticSelect, ) __all__ = ( @@ -30,29 +29,17 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ('Provider', ('name', 'slug', 'asns', 'description', 'tags')), + ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags', ] - widgets = { - 'noc_contact': SmallTextarea( - attrs={'rows': 5} - ), - 'admin_contact': SmallTextarea( - attrs={'rows': 5} - ), - } help_texts = { - 'name': "Full name of the provider", - 'asn': "BGP autonomous system number (if applicable)", - 'portal_url': "URL of the provider's customer support portal", - 'noc_contact': "NOC email address and phone number", - 'admin_contact': "Administrative contact email address and phone number", + 'name': _("Full name of the provider"), } @@ -111,8 +98,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { - 'cid': "Unique circuit ID", - 'commit_rate': "Committed rate", + 'cid': _("Unique circuit ID"), + 'commit_rate': _("Committed rate"), } widgets = { 'status': StaticSelect(), @@ -182,9 +169,9 @@ class CircuitTerminationForm(NetBoxModelForm): 'description', 'tags', ] help_texts = { - 'port_speed': "Physical circuit speed", - 'xconnect_id': "ID of the local cross-connect", - 'pp_info': "Patch panel ID and port number(s)" + 'port_speed': _("Physical circuit speed"), + 'xconnect_id': _("ID of the local cross-connect"), + 'pp_info': _("Patch panel ID and port number(s)") } widgets = { 'term_side': StaticSelect(), diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index e96fe98a5..5582de798 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,6 +1,8 @@ +import graphene + from circuits import filtersets, models from dcim.graphql.mixins import CabledObjectMixin -from extras.graphql.mixins import CustomFieldsMixin, TagsMixin +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( @@ -20,8 +22,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob filterset_class = filtersets.CircuitTerminationFilterSet -class CircuitType(NetBoxObjectType): - +class CircuitType(NetBoxObjectType, ContactsMixin): class Meta: model = models.Circuit fields = '__all__' @@ -36,7 +37,7 @@ class CircuitTypeType(OrganizationalObjectType): filterset_class = filtersets.CircuitTypeFilterSet -class ProviderType(NetBoxObjectType): +class ProviderType(NetBoxObjectType, ContactsMixin): class Meta: model = models.Provider diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 851f40a22..656eb35a1 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('cid', models.CharField(max_length=100)), ('status', models.CharField(default='active', max_length=50)), @@ -58,14 +58,14 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -73,7 +73,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -93,7 +93,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py index c686bf042..96b2a9d97 100644 --- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='circuittermination', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='circuittermination', diff --git a/netbox/circuits/migrations/0039_unique_constraints.py b/netbox/circuits/migrations/0039_unique_constraints.py new file mode 100644 index 000000000..1d5b62499 --- /dev/null +++ b/netbox/circuits/migrations/0039_unique_constraints.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0038_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='providernetwork', + name='circuits_providernetwork_provider_name', + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='circuit', + constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'), + ), + migrations.AddConstraint( + model_name='circuittermination', + constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'), + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'), + ), + ] diff --git a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py new file mode 100644 index 000000000..98c82204d --- /dev/null +++ b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py @@ -0,0 +1,59 @@ +import os + +from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy provider fields still contain data. + """ + Provider = apps.get_model('circuits', 'Provider') + + provider_count = Provider.objects.exclude(asn__isnull=True).count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} " + f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been " + f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider ASN data." + ) + + provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} " + f"providers with legacy contact data. Please ensure all legacy provider contact data has been " + f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider contact data." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0039_unique_constraints'), + ] + + operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='provider', + name='admin_contact', + ), + migrations.RemoveField( + model_name='provider', + name='asn', + ), + migrations.RemoveField( + model_name='provider', + name='noc_contact', + ), + migrations.RemoveField( + model_name='provider', + name='portal_url', + ), + ] diff --git a/netbox/circuits/migrations/0041_standardize_description_comments.py b/netbox/circuits/migrations/0041_standardize_description_comments.py new file mode 100644 index 000000000..49cdefcba --- /dev/null +++ b/netbox/circuits/migrations/0041_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0040_provider_remove_deprecated_fields'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index c08b5473a..8ef5761fd 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -3,11 +3,12 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from circuits.choices import * from dcim.models import CabledObjectModel from netbox.models import ( - ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin, ) from netbox.models.features import WebhooksMixin @@ -23,30 +24,11 @@ class CircuitType(OrganizationalModel): Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('circuits:circuittype', args=[self.pk]) -class Circuit(NetBoxModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -92,13 +74,6 @@ class Circuit(NetBoxModel): blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -129,18 +104,23 @@ class Circuit(NetBoxModel): clone_fields = ( 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', ) + prerequisite_models = ( + 'circuits.CircuitType', + 'circuits.Provider', + ) class Meta: ordering = ['provider', 'cid'] - unique_together = ['provider', 'cid'] + constraints = ( + models.UniqueConstraint( + fields=('provider', 'cid'), + name='%(app_label)s_%(class)s_unique_provider_cid' + ), + ) def __str__(self): return self.cid - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('circuits.Provider'), CircuitType] - def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) @@ -189,7 +169,7 @@ class CircuitTermination( blank=True, null=True, verbose_name='Upstream speed (Kbps)', - help_text='Upstream speed, if different from port speed' + help_text=_('Upstream speed, if different from port speed') ) xconnect_id = models.CharField( max_length=50, @@ -208,7 +188,12 @@ class CircuitTermination( class Meta: ordering = ['circuit', 'term_side'] - unique_together = ['circuit', 'term_side'] + constraints = ( + models.UniqueConstraint( + fields=('circuit', 'term_side'), + name='%(app_label)s_%(class)s_unique_circuit_term_side' + ), + ) def __str__(self): return f'Termination {self.term_side}: {self.site or self.provider_network}' diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index e136e13ea..18a81dcef 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -2,8 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from dcim.fields import ASNField -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( 'ProviderNetwork', @@ -11,7 +10,7 @@ __all__ = ( ) -class Provider(NetBoxModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -24,12 +23,6 @@ class Provider(NetBoxModel): max_length=100, unique=True ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='providers', @@ -40,21 +33,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - portal_url = models.URLField( - blank=True, - verbose_name='Portal URL' - ) - noc_contact = models.TextField( - blank=True, - verbose_name='NOC contact' - ) - admin_contact = models.TextField( - blank=True, - verbose_name='Admin contact' - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -62,7 +40,7 @@ class Provider(NetBoxModel): ) clone_fields = ( - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'account', ) class Meta: @@ -75,7 +53,7 @@ class Provider(NetBoxModel): return reverse('circuits:provider', args=[self.pk]) -class ProviderNetwork(NetBoxModel): +class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. @@ -93,23 +71,15 @@ class ProviderNetwork(NetBoxModel): blank=True, verbose_name='Service ID' ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) class Meta: ordering = ('provider', 'name') constraints = ( models.UniqueConstraint( fields=('provider', 'name'), - name='circuits_providernetwork_provider_name' + name='%(app_label)s_%(class)s_unique_provider_name' ), ) - unique_together = ('provider', 'name') def __str__(self): return self.name diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py new file mode 100644 index 000000000..2859295d5 --- /dev/null +++ b/netbox/circuits/search.py @@ -0,0 +1,56 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class CircuitIndex(SearchIndex): + model = models.Circuit + fields = ( + ('cid', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class CircuitTerminationIndex(SearchIndex): + model = models.CircuitTermination + fields = ( + ('xconnect_id', 300), + ('pp_info', 300), + ('description', 500), + ('port_speed', 2000), + ('upstream_speed', 2000), + ) + + +@register_search +class CircuitTypeIndex(SearchIndex): + model = models.CircuitType + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class ProviderIndex(SearchIndex): + model = models.Provider + fields = ( + ('name', 100), + ('account', 200), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ProviderNetworkIndex(SearchIndex): + model = models.ProviderNetwork + fields = ( + ('name', 100), + ('service_id', 200), + ('description', 500), + ('comments', 5000), + ) diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index e2a1d93f6..9de8d25b2 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -39,10 +39,10 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', - 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'description', 'comments', 'contacts', + 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') + default_columns = ('pk', 'name', 'account', 'circuit_count') class ProviderNetworkTable(NetBoxTable): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 02b489ac4..c9d2cfc40 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] bulk_update_data = { - 'asn': 1234, + 'account': '1234', } @classmethod diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 2646de3c2..897c87c05 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), - Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), - Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'), - Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'), - Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), + Provider(name='Provider 1', slug='provider-1', account='1234'), + Provider(name='Provider 2', slug='provider-2', account='2345'), + Provider(name='Provider 3', slug='provider-3', account='3456'), + Provider(name='Provider 4', slug='provider-4', account='4567'), + Provider(name='Provider 5', slug='provider-5', account='5678'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0]]) @@ -82,10 +82,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): # Legacy field - params = {'asn': ['65001', '65002']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn_id(self): # ASN object assignment asns = ASN.objects.all()[:2] params = {'asn_id': [asns[0].pk, asns[1].pk]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index fa6146b93..231d6a43c 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -23,9 +23,9 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), - Provider(name='Provider 3', slug='provider-3', asn=65003), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0], asns[1]]) @@ -37,12 +37,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'Provider X', 'slug': 'provider-x', - 'asn': 65123, 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', - 'portal_url': 'http://example.com/portal', - 'noc_contact': 'noc@example.com', - 'admin_contact': 'admin@example.com', 'comments': 'Another provider', 'tags': [t.pk for t in tags], } @@ -54,12 +50,15 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Provider 6,provider-6", ) + cls.csv_update_data = ( + "id,name,comments", + f"{providers[0].pk},Provider 7,New comment7", + f"{providers[1].pk},Provider 8,New comment8", + f"{providers[2].pk},Provider 9,New comment9", + ) + cls.bulk_edit_data = { - 'asn': 65009, 'account': '5678', - 'portal_url': 'http://example.com/portal2', - 'noc_contact': 'noc2@example.com', - 'admin_contact': 'admin2@example.com', 'comments': 'New comments', } @@ -70,11 +69,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - CircuitType.objects.bulk_create([ + circuit_types = ( CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 2', slug='circuit-type-2'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'), - ]) + ) + + CircuitType.objects.bulk_create(circuit_types) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -92,6 +93,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Circuit Type 6,circuit-type-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{circuit_types[0].pk},Circuit Type 7,New description7", + f"{circuit_types[1].pk},Circuit Type 8,New description8", + f"{circuit_types[2].pk},Circuit Type 9,New description9", + ) + cls.bulk_edit_data = { 'description': 'Foo', } @@ -100,12 +108,19 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit + def setUp(self): + super().setUp() + + self.add_permissions( + 'circuits.add_circuittermination', + ) + @classmethod def setUpTestData(cls): providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), ) Provider.objects.bulk_create(providers) @@ -115,11 +130,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) CircuitType.objects.bulk_create(circuittypes) - Circuit.objects.bulk_create([ + circuits = ( Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), - ]) + ) + + Circuit.objects.bulk_create(circuits) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -144,6 +161,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Circuit 6,Provider 1,Circuit Type 1,active", ) + cls.csv_update_data = ( + f"id,cid,description,status", + f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + ) + cls.bulk_edit_data = { 'provider': providers[1].pk, 'type': circuittypes[1].pk, @@ -167,11 +191,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Provider.objects.bulk_create(providers) - ProviderNetwork.objects.bulk_create([ + provider_networks = ( ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 2', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[0]), - ]) + ) + + ProviderNetwork.objects.bulk_create(provider_networks) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -190,6 +216,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Provider Network 6,Provider 1,Baz", ) + cls.csv_update_data = ( + "id,name,description", + f"{provider_networks[0].pk},Provider Network 7,New description7", + f"{provider_networks[1].pk},Provider Network 8,New description8", + f"{provider_networks[2].pk},Provider Network 9,New description9", + ) + cls.bulk_edit_data = { 'provider': providers[1].pk, 'description': 'New description', diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 5b15b29ac..d8c5ea276 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,9 +1,7 @@ -from django.urls import path +from django.urls import include, path -from dcim.views import PathTraceView -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'circuits' urlpatterns = [ @@ -14,11 +12,7 @@ urlpatterns = [ path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), - path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + path('providers//', include(get_model_urls('circuits', 'provider'))), # Provider networks path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), @@ -26,11 +20,7 @@ urlpatterns = [ path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'), path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'), path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'), - path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), - path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), - path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), - path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), - path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), + path('provider-networks//', include(get_model_urls('circuits', 'providernetwork'))), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), @@ -38,10 +28,7 @@ urlpatterns = [ path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), - path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//', include(get_model_urls('circuits', 'circuittype'))), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), @@ -49,17 +36,11 @@ urlpatterns = [ path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - path('circuits//', views.CircuitView.as_view(), name='circuit'), - path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), - path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), - path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), + path('circuits//', include(get_model_urls('circuits', 'circuit'))), # Circuit terminations path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), - path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), - path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuit-terminations//', include(get_model_urls('circuits', 'circuittermination'))), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 423bd67d6..3168509ba 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -3,9 +3,11 @@ from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render +from dcim.views import PathTraceView from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.utils import count_related +from utilities.views import register_model_view from . import filtersets, forms, tables from .models import * @@ -23,6 +25,7 @@ class ProviderListView(generic.ObjectListView): table = tables.ProviderTable +@register_model_view(Provider) class ProviderView(generic.ObjectView): queryset = Provider.objects.all() @@ -41,18 +44,20 @@ class ProviderView(generic.ObjectView): } +@register_model_view(Provider, 'edit') class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() form = forms.ProviderForm +@register_model_view(Provider, 'delete') class ProviderDeleteView(generic.ObjectDeleteView): queryset = Provider.objects.all() class ProviderBulkImportView(generic.BulkImportView): queryset = Provider.objects.all() - model_form = forms.ProviderCSVForm + model_form = forms.ProviderImportForm table = tables.ProviderTable @@ -84,6 +89,7 @@ class ProviderNetworkListView(generic.ObjectListView): table = tables.ProviderNetworkTable +@register_model_view(ProviderNetwork) class ProviderNetworkView(generic.ObjectView): queryset = ProviderNetwork.objects.all() @@ -103,18 +109,20 @@ class ProviderNetworkView(generic.ObjectView): } +@register_model_view(ProviderNetwork, 'edit') class ProviderNetworkEditView(generic.ObjectEditView): queryset = ProviderNetwork.objects.all() form = forms.ProviderNetworkForm +@register_model_view(ProviderNetwork, 'delete') class ProviderNetworkDeleteView(generic.ObjectDeleteView): queryset = ProviderNetwork.objects.all() class ProviderNetworkBulkImportView(generic.BulkImportView): queryset = ProviderNetwork.objects.all() - model_form = forms.ProviderNetworkCSVForm + model_form = forms.ProviderNetworkImportForm table = tables.ProviderNetworkTable @@ -144,6 +152,7 @@ class CircuitTypeListView(generic.ObjectListView): table = tables.CircuitTypeTable +@register_model_view(CircuitType) class CircuitTypeView(generic.ObjectView): queryset = CircuitType.objects.all() @@ -157,18 +166,20 @@ class CircuitTypeView(generic.ObjectView): } +@register_model_view(CircuitType, 'edit') class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() form = forms.CircuitTypeForm +@register_model_view(CircuitType, 'delete') class CircuitTypeDeleteView(generic.ObjectDeleteView): queryset = CircuitType.objects.all() class CircuitTypeBulkImportView(generic.BulkImportView): queryset = CircuitType.objects.all() - model_form = forms.CircuitTypeCSVForm + model_form = forms.CircuitTypeImportForm table = tables.CircuitTypeTable @@ -202,23 +213,36 @@ class CircuitListView(generic.ObjectListView): table = tables.CircuitTable +@register_model_view(Circuit) class CircuitView(generic.ObjectView): queryset = Circuit.objects.all() +@register_model_view(Circuit, 'edit') class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() form = forms.CircuitForm +@register_model_view(Circuit, 'delete') class CircuitDeleteView(generic.ObjectDeleteView): queryset = Circuit.objects.all() class CircuitBulkImportView(generic.BulkImportView): queryset = Circuit.objects.all() - model_form = forms.CircuitCSVForm + model_form = forms.CircuitImportForm table = tables.CircuitTable + additional_permissions = [ + 'circuits.add_circuittermination', + ] + related_object_forms = { + 'terminations': forms.CircuitTerminationImportForm, + } + + def prep_related_object_data(self, parent, data): + data.update({'circuit': parent}) + return data class CircuitBulkEditView(generic.BulkEditView): @@ -318,11 +342,17 @@ class CircuitSwapTerminations(generic.ObjectEditView): # Circuit terminations # +@register_model_view(CircuitTermination, 'edit') class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' +@register_model_view(CircuitTermination, 'delete') class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() + + +# Trace view +register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index f5e06e155..29881a548 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -45,6 +45,7 @@ __all__ = [ 'NestedSiteSerializer', 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', + 'NestedVirtualDeviceContextSerializer', ] @@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): class Meta: model = models.PowerFeed fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] + + +class NestedVirtualDeviceContextSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') + device = NestedDeviceSerializer() + + class Meta: + model = models.VirtualDeviceContext + fields = ['id', 'url', 'display', 'name', 'identifier', 'device'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cb1edfe1f..f8a8d4e1e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,6 +1,7 @@ import decimal from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from timezone_field.rest_framework import TimeZoneSerializerField @@ -197,10 +198,11 @@ class RackSerializer(NetBoxModelSerializer): status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) - facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID', + facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), default=None) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -208,8 +210,9 @@ class RackSerializer(NetBoxModelSerializer): model = Rack fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units', + 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', ] @@ -241,8 +244,8 @@ class RackReservationSerializer(NetBoxModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', - 'custom_fields', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', + 'comments', 'tags', 'custom_fields', ] @@ -309,33 +312,34 @@ class DeviceTypeSerializer(NetBoxModelSerializer): u_height = serializers.DecimalField( max_digits=4, decimal_places=1, - label='Position (U)', + label=_('Position (U)'), min_value=0, default=1.0 ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', + 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() - # module_count = serializers.IntegerField(read_only=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -633,7 +637,7 @@ class DeviceSerializer(NetBoxModelSerializer): max_digits=4, decimal_places=1, allow_null=True, - label='Position (U)', + label=_('Position (U)'), min_value=decimal.Decimal(0.5), default=None ) @@ -652,8 +656,8 @@ class DeviceSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) @@ -668,17 +672,37 @@ class DeviceSerializer(NetBoxModelSerializer): return data +class VirtualDeviceContextSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + device = NestedDeviceSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + + # Related object counts + interface_count = serializers.IntegerField(read_only=True) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count', + ] + + class ModuleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer() module_bay = NestedModuleBaySerializer() module_type = NestedModuleTypeSerializer() + status = ChoiceField(choices=ModuleStatusChoices, required=False) class Meta: model = Module fields = [ - 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -820,6 +844,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + vdcs = SerializedPKRelatedField( + queryset=VirtualDeviceContext.objects.all(), + serializer=NestedVirtualDeviceContextSerializer, + required=False, + many=True + ) module = ComponentNestedModuleSerializer( required=False, allow_null=True @@ -856,13 +886,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', - 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', - 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', + 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', + 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', + 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -1016,7 +1046,7 @@ class CableSerializer(NetBoxModelSerializer): model = Cable fields = [ 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated', + 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1082,8 +1112,8 @@ class VirtualChassisSerializer(NetBoxModelSerializer): class Meta: model = VirtualChassis fields = [ - 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', + 'member_count', 'created', 'last_updated', ] @@ -1104,8 +1134,8 @@ class PowerPanelSerializer(NetBoxModelSerializer): class Meta: model = PowerPanel fields = [ - 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', + 'powerfeed_count', 'created', 'last_updated', ] @@ -1138,7 +1168,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', - 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 47bbfd525..36a0c99a5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('virtual-device-contexts', views.VirtualDeviceContextViewSet) router.register('modules', views.ModuleViewSet) # Device components diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c18eab01f..954c6a2a5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -538,6 +538,16 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return Response(response) +class VirtualDeviceContextViewSet(NetBoxModelViewSet): + queryset = VirtualDeviceContext.objects.prefetch_related( + 'device__device_type', 'device', 'tenant', 'tags', + ).annotate( + interface_count=count_related(Interface, 'vdcs'), + ) + serializer_class = serializers.VirtualDeviceContextSerializer + filterset_class = filtersets.VirtualDeviceContextFilterSet + + class ModuleViewSet(NetBoxModelViewSet): queryset = Module.objects.prefetch_related( 'device', 'module_bay', 'module_type__manufacturer', 'tags', diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 4be2df659..bfb09e601 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -8,7 +8,7 @@ class DCIMConfig(AppConfig): verbose_name = "DCIM" def ready(self): - import dcim.signals + from . import signals, search from .models import CableTermination # Register denormalized fields diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fdd0b025f..32dbbb62a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -198,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet): ) +# +# Modules +# + +class ModuleStatusChoices(ChoiceSet): + key = 'Module.status' + + STATUS_OFFLINE = 'offline' + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' + STATUS_STAGED = 'staged' + STATUS_FAILED = 'failed' + STATUS_DECOMMISSIONING = 'decommissioning' + + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + ] + + # # ConsolePorts # @@ -1343,6 +1367,24 @@ class CableLengthUnitChoices(ChoiceSet): ) +class WeightUnitChoices(ChoiceSet): + + # Metric + UNIT_KILOGRAM = 'kg' + UNIT_GRAM = 'g' + + # Imperial + UNIT_POUND = 'lb' + UNIT_OUNCE = 'oz' + + CHOICES = ( + (UNIT_KILOGRAM, 'Kilograms'), + (UNIT_GRAM, 'Grams'), + (UNIT_POUND, 'Pounds'), + (UNIT_OUNCE, 'Ounces'), + ) + + # # CableTerminations # @@ -1410,3 +1452,20 @@ class PowerFeedPhaseChoices(ChoiceSet): (PHASE_SINGLE, 'Single phase'), (PHASE_3PHASE, 'Three-phase'), ) + + +# +# VDC +# +class VirtualDeviceContextStatusChoices(ChoiceSet): + key = 'VirtualDeviceContext.status' + + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 917f57923..1ebcb2e34 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.contrib.auth.models import User +from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF @@ -65,19 +66,20 @@ __all__ = ( 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', + 'VirtualDeviceContextFilterSet', ) class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), - label='Parent region (ID)', + label=_('Parent region (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=Region.objects.all(), to_field_name='slug', - label='Parent region (slug)', + label=_('Parent region (slug)'), ) class Meta: @@ -88,13 +90,13 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - label='Parent site group (ID)', + label=_('Parent site group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=SiteGroup.objects.all(), to_field_name='slug', - label='Parent site group (slug)', + label=_('Parent site group (slug)'), ) class Meta: @@ -111,36 +113,36 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=Region.objects.all(), field_name='region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='group', lookup_expr='in', - label='Group (ID)', + label=_('Group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), lookup_expr='in', to_field_name='slug', - label='Group (slug)', + label=_('Group (slug)'), ) asn = django_filters.ModelMultipleChoiceFilter( field_name='asns__asn', queryset=ASN.objects.all(), to_field_name='asn', - label='AS (ID)', + label=_('AS (ID)'), ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), - label='AS (ID)', + label=_('AS (ID)'), ) class Meta: @@ -172,50 +174,50 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) parent_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) parent = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=LocationStatusChoices, @@ -247,50 +249,50 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) location = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, @@ -304,13 +306,13 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=RackRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) serial = MultiValueCharFilter( lookup_expr='iexact' @@ -320,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = [ 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', + 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit' ] def search(self, queryset, name, value): @@ -338,67 +340,67 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): rack_id = django_filters.ModelMultipleChoiceFilter( queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='rack__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='rack__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='rack__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='rack__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) location = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) class Meta: @@ -426,63 +428,63 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class DeviceTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) has_front_image = django_filters.BooleanFilter( - label='Has a front image', + label=_('Has a front image'), method='_has_front_image' ) has_rear_image = django_filters.BooleanFilter( - label='Has a rear image', + label=_('Has a rear image'), method='_has_rear_image' ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) module_bays = django_filters.BooleanFilter( method='_module_bays', - label='Has module bays', + label=_('Has module bays'), ) device_bays = django_filters.BooleanFilter( method='_device_bays', - label='Has device bays', + label=_('Has device bays'), ) inventory_items = django_filters.BooleanFilter( method='_inventory_items', - label='Has inventory items', + label=_('Has inventory items'), ) class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', ] def search(self, queryset, name, value): @@ -541,42 +543,42 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class ModuleTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) class Meta: model = ModuleType - fields = ['id', 'model', 'part_number'] + fields = ['id', 'model', 'part_number', 'weight', 'weight_unit'] def search(self, queryset, name, value): if not value.strip(): @@ -613,12 +615,12 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', - label='Device type (ID)', + label=_('Device type (ID)'), ) def search(self, queryset, name, value): @@ -631,7 +633,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): moduletype_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleType.objects.all(), field_name='module_type_id', - label='Module type (ID)', + label=_('Module type (ID)'), ) @@ -723,27 +725,27 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemTemplate.objects.all(), - label='Parent inventory item (ID)', + label=_('Parent inventory item (ID)'), ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=InventoryItemRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) component_type = ContentTypeFilter() component_id = MultiValueNumberFilter() @@ -774,13 +776,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) class Meta: @@ -792,106 +794,109 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) device_type = django_filters.ModelMultipleChoiceFilter( field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', - label='Device type (slug)', + label=_('Device type (slug)'), ) device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), - label='Device type (ID)', + label=_('Device type (ID)'), ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='device_role_id', queryset=DeviceRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='device_role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) parent_device_id = django_filters.ModelMultipleChoiceFilter( field_name='parent_bay__device', queryset=Device.objects.all(), - label='Parent Device (ID)', + label=_('Parent Device (ID)'), ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), - label='Platform (ID)', + label=_('Platform (ID)'), ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', - label='Platform (slug)', + label=_('Platform (slug)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), - label='VM cluster (ID)', + label=_('VM cluster (ID)'), ) model = django_filters.ModelMultipleChoiceFilter( field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', - label='Device model (slug)', + label=_('Device model (slug)'), + ) + name = MultiValueCharFilter( + lookup_expr='iexact' ) status = django_filters.MultipleChoiceFilter( choices=DeviceStatusChoices, @@ -899,64 +904,64 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter ) is_full_depth = django_filters.BooleanFilter( field_name='device_type__is_full_depth', - label='Is full depth', + label=_('Is full depth'), ) mac_address = MultiValueMACAddressFilter( field_name='interfaces__mac_address', - label='MAC address', + label=_('MAC address'), ) serial = MultiValueCharFilter( lookup_expr='iexact' ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP'), ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), - label='Virtual chassis (ID)', + label=_('Virtual chassis (ID)'), ) virtual_chassis_member = django_filters.BooleanFilter( method='_virtual_chassis_member', - label='Is a virtual chassis member' + label=_('Is a virtual chassis member') ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) module_bays = django_filters.BooleanFilter( method='_module_bays', - label='Has module bays', + label=_('Has module bays'), ) device_bays = django_filters.BooleanFilter( method='_device_bays', - label='Has device bays', + label=_('Has device bays'), ) class Meta: model = Device - fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] + fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -1006,38 +1011,80 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.exclude(devicebays__isnull=value) +class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='VDC (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='Device model', + ) + status = django_filters.MultipleChoiceFilter( + choices=VirtualDeviceContextStatusChoices + ) + has_primary_ip = django_filters.BooleanFilter( + method='_has_primary_ip', + label='Has a primary IP', + ) + + class Meta: + model = VirtualDeviceContext + fields = ['id', 'device', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(identifier=value.strip()) + ).distinct() + + def _has_primary_ip(self, queryset, name, value): + params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) + if value: + return queryset.filter(params) + return queryset.exclude(params) + + class ModuleFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) module_type_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type', queryset=ModuleType.objects.all(), - label='Module type (ID)', + label=_('Module type (ID)'), ) module_type = django_filters.ModelMultipleChoiceFilter( field_name='module_type__model', queryset=ModuleType.objects.all(), to_field_name='model', - label='Module type (model)', + label=_('Module type (model)'), ) module_bay_id = django_filters.ModelMultipleChoiceFilter( field_name='module_bay', queryset=ModuleBay.objects.all(), to_field_name='id', - label='Module Bay (ID)' + label=_('Module Bay (ID)') ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), + ) + status = django_filters.MultipleChoiceFilter( + choices=ModuleStatusChoices, + null_value=None ) serial = MultiValueCharFilter( lookup_expr='iexact' @@ -1045,7 +1092,7 @@ class ModuleFilterSet(NetBoxModelFilterSet): class Meta: model = Module - fields = ['id', 'asset_tag'] + fields = ['id', 'status', 'asset_tag'] def search(self, queryset, name, value): if not value.strip(): @@ -1060,87 +1107,87 @@ class ModuleFilterSet(NetBoxModelFilterSet): class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='device__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='device__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='device__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='device__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='device__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( field_name='device__location', queryset=Location.objects.all(), - label='Location (ID)', + label=_('Location (ID)'), ) location = django_filters.ModelMultipleChoiceFilter( field_name='device__location__slug', queryset=Location.objects.all(), to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='device__rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) rack = django_filters.ModelMultipleChoiceFilter( field_name='device__rack__name', queryset=Rack.objects.all(), to_field_name='name', - label='Rack (name)', + label=_('Rack (name)'), ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis', queryset=VirtualChassis.objects.all(), - label='Virtual Chassis (ID)' + label=_('Virtual Chassis (ID)') ) virtual_chassis = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis__name', queryset=VirtualChassis.objects.all(), to_field_name='name', - label='Virtual Chassis', + label=_('Virtual Chassis'), ) def search(self, queryset, name, value): @@ -1160,7 +1207,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): """ module_id = django_filters.ModelMultipleChoiceFilter( queryset=Module.objects.all(), - label='Module (ID)', + label=_('Module (ID)'), ) @@ -1272,31 +1319,31 @@ class InterfaceFilterSet( device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device', + label=_('Device'), ) device_id = MultiValueNumberFilter( method='filter_device_id', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) kind = django_filters.CharFilter( method='filter_kind', - label='Kind of interface', + label=_('Kind of interface'), ) parent_id = django_filters.ModelMultipleChoiceFilter( field_name='parent', queryset=Interface.objects.all(), - label='Parent interface (ID)', + label=_('Parent interface (ID)'), ) bridge_id = django_filters.ModelMultipleChoiceFilter( field_name='bridge', queryset=Interface.objects.all(), - label='Bridged interface (ID)', + label=_('Bridged interface (ID)'), ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), - label='LAG interface (ID)', + label=_('LAG interface (ID)'), ) speed = MultiValueNumberFilter() duplex = django_filters.MultipleChoiceFilter( @@ -1312,11 +1359,11 @@ class InterfaceFilterSet( ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', - label='Assigned VLAN' + label=_('Assigned VLAN') ) vlan = django_filters.CharFilter( method='filter_vlan', - label='Assigned VID' + label=_('Assigned VID') ) type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, @@ -1331,13 +1378,30 @@ class InterfaceFilterSet( vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='vrf', queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), + ) + vdc_id = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs', + queryset=VirtualDeviceContext.objects.all(), + label='Virtual Device Context', + ) + vdc_identifier = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__identifier', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='identifier', + label='Virtual Device Context (Identifier)', + ) + vdc = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__name', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='name', + label='Virtual Device Context', ) class Meta: @@ -1442,27 +1506,27 @@ class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), - label='Parent inventory item (ID)', + label=_('Parent inventory item (ID)'), ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=InventoryItemRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) component_type = ContentTypeFilter() component_id = MultiValueNumberFilter() @@ -1497,61 +1561,61 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class VirtualChassisFilterSet(NetBoxModelFilterSet): master_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Master (ID)', + label=_('Master (ID)'), ) master = django_filters.ModelMultipleChoiceFilter( field_name='master__name', queryset=Device.objects.all(), to_field_name='name', - label='Master (name)', + label=_('Master (name)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='master__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='master__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='master__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='master__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='master__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant', queryset=Tenant.objects.all(), - label='Tenant (ID)', + label=_('Tenant (ID)'), ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) class Meta: @@ -1650,43 +1714,43 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) class Meta: @@ -1707,47 +1771,47 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi queryset=Region.objects.all(), field_name='power_panel__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='power_panel__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='power_panel__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) power_panel_id = django_filters.ModelMultipleChoiceFilter( queryset=PowerPanel.objects.all(), - label='Power panel (ID)', + label=_('Power panel (ID)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) status = django_filters.MultipleChoiceFilter( choices=PowerFeedStatusChoices, @@ -1777,7 +1841,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi class ConnectionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) site_id = MultiValueNumberFilter( method='filter_connections', diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py index 22f0b1204..7510a979f 100644 --- a/netbox/dcim/forms/__init__.py +++ b/netbox/dcim/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .object_create import * from .object_import import * diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index f6bc27079..11fdfa6d2 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -1,6 +1,7 @@ from django import forms from dcim.models import * +from django.utils.translation import gettext as _ from extras.forms import CustomFieldsMixin from extras.models import Tag from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model @@ -105,9 +106,9 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): field_order = ('name', 'label', 'position_pattern', 'description', 'tags') replication_fields = ('name', 'label', 'position') position_pattern = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index db2d3ca2a..38fa55738 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms -from django.utils.translation import gettext as _ from django.contrib.auth.models import User +from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField from dcim.choices import * @@ -54,6 +54,7 @@ __all__ = ( 'SiteBulkEditForm', 'SiteGroupBulkEditForm', 'VirtualChassisBulkEditForm', + 'VirtualDeviceContextBulkEditForm' ) @@ -125,24 +126,28 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): ) contact_email = forms.EmailField( required=False, - label='Contact E-mail' - ) - description = forms.CharField( - max_length=100, - required=False + label=_('Contact E-mail') ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), required=False, widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Site fieldsets = ( (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), ) nullable_fields = ( - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments', ) @@ -243,7 +248,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') ) asset_tag = forms.CharField( max_length=50, @@ -261,12 +266,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) u_height = forms.IntegerField( required=False, - label='Height (U)' + label=_('Height (U)') ) desc_units = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Descending units' + label=_('Descending units') ) outer_width = forms.IntegerField( required=False, @@ -281,6 +286,28 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + mounting_depth = forms.IntegerField( + required=False, + min_value=1 + ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + max_weight = forms.IntegerField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -288,12 +315,16 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): model = Rack fieldsets = ( - ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), + ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')), ('Location', ('region', 'site_group', 'site', 'location')), - ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), + ('Hardware', ( + 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', + )), + ('Weight', ('weight', 'max_weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -310,14 +341,19 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RackReservation fieldsets = ( (None, ('user', 'tenant', 'description')), ) + nullable_fields = ('comments',) class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): @@ -348,19 +384,38 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): is_full_depth = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Is full depth' + label=_('Is full depth') ) airflow = forms.ChoiceField( choices=add_blank_choice(DeviceAirflowChoices), required=False, widget=StaticSelect() ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = DeviceType fieldsets = ( - (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'airflow') + nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -371,12 +426,31 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): part_number = forms.CharField( required=False ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ModuleType fieldsets = ( - (None, ('manufacturer', 'part_number')), + ('Module Type', ('manufacturer', 'part_number', 'description')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number',) + nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -386,7 +460,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): vm_role = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='VM role' + label=_('VM role') ) description = forms.CharField( max_length=200, @@ -470,17 +544,25 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' ) model = Device fieldsets = ( - ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')), ('Location', ('site', 'location')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) nullable_fields = ( - 'location', 'tenant', 'platform', 'serial', 'airflow', + 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', ) @@ -496,17 +578,31 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): 'manufacturer_id': '$manufacturer' } ) + status = forms.ChoiceField( + choices=add_blank_choice(ModuleStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' ) model = Module fieldsets = ( - (None, ('manufacturer', 'module_type', 'serial')), + (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')), ) - nullable_fields = ('serial',) + nullable_fields = ('serial', 'description', 'comments') class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -543,39 +639,44 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Cable fieldsets = ( - (None, ('type', 'status', 'tenant', 'label')), + (None, ('type', 'status', 'tenant', 'label', 'description')), ('Attributes', ('color', 'length', 'length_unit')), ) nullable_fields = ( - 'type', 'status', 'tenant', 'label', 'color', 'length', + 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', ) - def clean(self): - super().clean() - - # Validate length/unit - length = self.cleaned_data.get('length') - length_unit = self.cleaned_data.get('length_unit') - if length and not length_unit: - raise forms.ValidationError({ - 'length_unit': "Must specify a unit when setting length" - }) - class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): domain = forms.CharField( max_length=30, required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VirtualChassis fieldsets = ( - (None, ('domain',)), + (None, ('domain', 'description')), ) - nullable_fields = ('domain',) + nullable_fields = ('domain', 'description', 'comments') class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): @@ -608,12 +709,20 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = PowerPanel fieldsets = ( - (None, ('region', 'site_group', 'site', 'location')), + (None, ('region', 'site_group', 'site', 'location', 'description')), ) - nullable_fields = ('location',) + nullable_fields = ('location', 'description', 'comments') class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): @@ -662,17 +771,21 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=BulkEditNullBooleanSelect ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')), ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) ) - nullable_fields = ('location', 'comments') + nullable_fields = ('location', 'description', 'comments') # @@ -735,12 +848,12 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): maximum_draw = forms.IntegerField( min_value=1, required=False, - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = forms.IntegerField( min_value=1, required=False, - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) description = forms.CharField( required=False @@ -813,7 +926,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Management only' + label=_('Management only') ) description = forms.CharField( required=False @@ -823,14 +936,14 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): required=False, initial='', widget=StaticSelect(), - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE type' + label=_('PoE type') ) nullable_fields = ('label', 'description', 'poe_mode', 'poe_type') @@ -1071,31 +1184,31 @@ class InterfaceBulkEditForm( query_params={ 'type': 'lag', }, - label='LAG' + label=_('LAG') ) speed = forms.IntegerField( required=False, widget=SelectSpeedWidget(), - label='Speed' + label=_('Speed') ) mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Management only' + label=_('Management only') ) poe_mode = forms.ChoiceField( choices=add_blank_choice(InterfacePoEModeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE type' + label=_('PoE type') ) mark_connected = forms.NullBooleanField( required=False, @@ -1110,7 +1223,7 @@ class InterfaceBulkEditForm( vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -1118,7 +1231,7 @@ class InterfaceBulkEditForm( query_params={ 'group_id': '$vlan_group', }, - label='Untagged VLAN' + label=_('Untagged VLAN') ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), @@ -1126,12 +1239,12 @@ class InterfaceBulkEditForm( query_params={ 'group_id': '$vlan_group', }, - label='Tagged VLANs' + label=_('Tagged VLANs') ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) model = Interface @@ -1296,3 +1409,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): (None, ('color', 'description')), ) nullable_fields = ('color', 'description') + + +class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices), + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + model = VirtualDeviceContext + fieldsets = ( + (None, ('device', 'status', 'tenant')), + ) + nullable_fields = ('device', 'tenant', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 5c6cbb0b0..940d05127 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -3,12 +3,13 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VRF -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -16,56 +17,59 @@ from wireless.choices import WirelessRoleChoices from .common import ModuleCommonForm __all__ = ( - 'CableCSVForm', - 'ChildDeviceCSVForm', - 'ConsolePortCSVForm', - 'ConsoleServerPortCSVForm', - 'DeviceBayCSVForm', - 'DeviceCSVForm', - 'DeviceRoleCSVForm', - 'FrontPortCSVForm', - 'InterfaceCSVForm', - 'InventoryItemCSVForm', - 'InventoryItemRoleCSVForm', - 'LocationCSVForm', - 'ManufacturerCSVForm', - 'ModuleCSVForm', - 'ModuleBayCSVForm', - 'PlatformCSVForm', - 'PowerFeedCSVForm', - 'PowerOutletCSVForm', - 'PowerPanelCSVForm', - 'PowerPortCSVForm', - 'RackCSVForm', - 'RackReservationCSVForm', - 'RackRoleCSVForm', - 'RearPortCSVForm', - 'RegionCSVForm', - 'SiteCSVForm', - 'SiteGroupCSVForm', - 'VirtualChassisCSVForm', + 'CableImportForm', + 'ChildDeviceImportForm', + 'ConsolePortImportForm', + 'ConsoleServerPortImportForm', + 'DeviceBayImportForm', + 'DeviceImportForm', + 'DeviceRoleImportForm', + 'DeviceTypeImportForm', + 'FrontPortImportForm', + 'InterfaceImportForm', + 'InventoryItemImportForm', + 'InventoryItemRoleImportForm', + 'LocationImportForm', + 'ManufacturerImportForm', + 'ModuleImportForm', + 'ModuleBayImportForm', + 'ModuleTypeImportForm', + 'PlatformImportForm', + 'PowerFeedImportForm', + 'PowerOutletImportForm', + 'PowerPanelImportForm', + 'PowerPortImportForm', + 'RackImportForm', + 'RackReservationImportForm', + 'RackRoleImportForm', + 'RearPortImportForm', + 'RegionImportForm', + 'SiteImportForm', + 'SiteGroupImportForm', + 'VirtualChassisImportForm', + 'VirtualDeviceContextImportForm' ) -class RegionCSVForm(NetBoxModelCSVForm): +class RegionImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of parent region' + help_text=_('Name of parent region') ) class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') -class SiteGroupCSVForm(NetBoxModelCSVForm): +class SiteGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent site group' + help_text=_('Name of parent site group') ) class Meta: @@ -73,86 +77,86 @@ class SiteGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteCSVForm(NetBoxModelCSVForm): +class SiteImportForm(NetBoxModelImportForm): status = CSVChoiceField( choices=SiteStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) region = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Assigned region' + help_text=_('Assigned region') ) group = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags' ) help_texts = { 'time_zone': mark_safe( - 'Time zone (available options)' + _('Time zone (available options)') ) } -class LocationCSVForm(NetBoxModelCSVForm): +class LocationImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) parent = CSVModelChoiceField( queryset=Location.objects.all(), required=False, to_field_name='name', - help_text='Parent location', + help_text=_('Parent location'), error_messages={ - 'invalid_choice': 'Location not found.', + 'invalid_choice': _('Location not found.'), } ) status = CSVChoiceField( choices=LocationStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') -class RackRoleCSVForm(NetBoxModelCSVForm): +class RackRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = RackRole - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'description', 'tags') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } -class RackCSVForm(NetBoxModelCSVForm): +class RackImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' @@ -166,38 +170,44 @@ class RackCSVForm(NetBoxModelCSVForm): queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant' + help_text=_('Name of assigned tenant') ) status = CSVChoiceField( choices=RackStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned role' + help_text=_('Name of assigned role') ) type = CSVChoiceField( choices=RackTypeChoices, required=False, - help_text='Rack type' + help_text=_('Rack type') ) width = forms.ChoiceField( choices=RackWidthChoices, - help_text='Rail-to-rail width (in inches)' + help_text=_('Rail-to-rail width (in inches)') ) outer_unit = CSVChoiceField( choices=RackDimensionUnitChoices, required=False, - help_text='Unit for outer dimensions' + help_text=_('Unit for outer dimensions') + ) + weight_unit = CSVChoiceField( + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for rack weights') ) class Meta: model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', + 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -210,38 +220,38 @@ class RackCSVForm(NetBoxModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class RackReservationCSVForm(NetBoxModelCSVForm): +class RackReservationImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Parent site' + help_text=_('Parent site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's location (if any)" + help_text=_("Rack's location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', - help_text='Rack' + help_text=_('Rack') ) units = SimpleArrayField( base_field=forms.IntegerField(), required=True, - help_text='Comma-separated list of individual unit numbers' + help_text=_('Comma-separated list of individual unit numbers') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -260,81 +270,106 @@ class RackReservationCSVForm(NetBoxModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ManufacturerCSVForm(NetBoxModelCSVForm): +class ManufacturerImportForm(NetBoxModelImportForm): class Meta: model = Manufacturer - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') -class DeviceRoleCSVForm(NetBoxModelCSVForm): +class DeviceTypeImportForm(NetBoxModelImportForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = DeviceType + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'description', 'comments', + ] + + +class ModuleTypeImportForm(NetBoxModelImportForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleType + fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] + + +class DeviceRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'description') + fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } -class PlatformCSVForm(NetBoxModelCSVForm): +class PlatformImportForm(NetBoxModelImportForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), required=False, to_field_name='name', - help_text='Limit platform assignments to this manufacturer' + help_text=_('Limit platform assignments to this manufacturer') ) class Meta: model = Platform - fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') + fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') -class BaseDeviceCSVForm(NetBoxModelCSVForm): +class BaseDeviceImportForm(NetBoxModelImportForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', - help_text='Assigned role' + help_text=_('Assigned role') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', - help_text='Device type manufacturer' + help_text=_('Device type manufacturer') ) device_type = CSVModelChoiceField( queryset=DeviceType.objects.all(), to_field_name='model', - help_text='Device type model' + help_text=_('Device type model') ) platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Assigned platform' + help_text=_('Assigned platform') ) status = CSVChoiceField( choices=DeviceStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) virtual_chassis = CSVModelChoiceField( queryset=VirtualChassis.objects.all(), to_field_name='name', required=False, - help_text='Virtual chassis' + help_text=_('Virtual chassis') ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', required=False, - help_text='Virtualization cluster' + help_text=_('Virtualization cluster') ) class Meta: @@ -355,40 +390,40 @@ class BaseDeviceCSVForm(NetBoxModelCSVForm): self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params) -class DeviceCSVForm(BaseDeviceCSVForm): +class DeviceImportForm(BaseDeviceImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Assigned location (if any)" + help_text=_("Assigned location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text="Assigned rack (if any)" + help_text=_("Assigned rack (if any)") ) face = CSVChoiceField( choices=DeviceFaceChoices, required=False, - help_text='Mounted rack face' + help_text=_('Mounted rack face') ) airflow = CSVChoiceField( choices=DeviceAirflowChoices, required=False, - help_text='Airflow direction' + help_text=_('Airflow direction') ) - class Meta(BaseDeviceCSVForm.Meta): + class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'comments', + 'cluster', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -408,33 +443,40 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ModuleCSVForm(ModuleCommonForm, NetBoxModelCSVForm): +class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name' + to_field_name='name', + help_text=_('The device in which this module is installed') ) module_bay = CSVModelChoiceField( queryset=ModuleBay.objects.all(), - to_field_name='name' + to_field_name='name', + help_text=_('The module bay in which this module is installed') ) module_type = CSVModelChoiceField( queryset=ModuleType.objects.all(), - to_field_name='model' + to_field_name='model', + help_text=_('The type of module') + ) + status = CSVChoiceField( + choices=ModuleStatusChoices, + help_text=_('Operational status') ) replicate_components = forms.BooleanField( required=False, - help_text="Automatically populate components associated with this module type (default: true)" + help_text=_('Automatically populate components associated with this module type (enabled by default)') ) adopt_components = forms.BooleanField( required=False, - help_text="Adopt already existing components" + help_text=_('Adopt already existing components') ) class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'replicate_components', - 'adopt_components', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments', + 'replicate_components', 'adopt_components', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -453,22 +495,22 @@ class ModuleCSVForm(ModuleCommonForm, NetBoxModelCSVForm): return self.cleaned_data['replicate_components'] -class ChildDeviceCSVForm(BaseDeviceCSVForm): +class ChildDeviceImportForm(BaseDeviceImportForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Parent device' + help_text=_('Parent device') ) device_bay = CSVModelChoiceField( queryset=DeviceBay.objects.all(), to_field_name='name', - help_text='Device bay in which this device is installed' + help_text=_('Device bay in which this device is installed') ) - class Meta(BaseDeviceCSVForm.Meta): + class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags' ] def __init__(self, data=None, *args, **kwargs): @@ -499,7 +541,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): # Device components # -class ConsolePortCSVForm(NetBoxModelCSVForm): +class ConsolePortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -507,22 +549,22 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=ConsolePortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) speed = CSVTypedChoiceField( choices=ConsolePortSpeedChoices, coerce=int, empty_value=None, required=False, - help_text='Port speed in bps' + help_text=_('Port speed in bps') ) class Meta: model = ConsolePort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') -class ConsoleServerPortCSVForm(NetBoxModelCSVForm): +class ConsoleServerPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -530,22 +572,22 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=ConsolePortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) speed = CSVTypedChoiceField( choices=ConsolePortSpeedChoices, coerce=int, empty_value=None, required=False, - help_text='Port speed in bps' + help_text=_('Port speed in bps') ) class Meta: model = ConsoleServerPort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') -class PowerPortCSVForm(NetBoxModelCSVForm): +class PowerPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -553,17 +595,17 @@ class PowerPortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=PowerPortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) class Meta: model = PowerPort fields = ( - 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', 'tags' ) -class PowerOutletCSVForm(NetBoxModelCSVForm): +class PowerOutletImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -571,29 +613,29 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=PowerOutletTypeChoices, required=False, - help_text='Outlet type' + help_text=_('Outlet type') ) power_port = CSVModelChoiceField( queryset=PowerPort.objects.all(), required=False, to_field_name='name', - help_text='Local power port which feeds this outlet' + help_text=_('Local power port which feeds this outlet') ) feed_leg = CSVChoiceField( choices=PowerOutletFeedLegChoices, required=False, - help_text='Electrical phase (for three-phase circuits)' + help_text=_('Electrical phase (for three-phase circuits)') ) class Meta: model = PowerOutlet - fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description') + fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Limit PowerPort choices to those belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -612,7 +654,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class InterfaceCSVForm(NetBoxModelCSVForm): +class InterfaceImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -621,23 +663,23 @@ class InterfaceCSVForm(NetBoxModelCSVForm): queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Parent interface' + help_text=_('Parent interface') ) bridge = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Bridged interface' + help_text=_('Bridged interface') ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Parent LAG interface' + help_text=_('Parent LAG interface') ) type = CSVChoiceField( choices=InterfaceTypeChoices, - help_text='Physical medium' + help_text=_('Physical medium') ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, @@ -646,28 +688,28 @@ class InterfaceCSVForm(NetBoxModelCSVForm): poe_mode = CSVChoiceField( choices=InterfacePoEModeChoices, required=False, - help_text='PoE mode' + help_text=_('PoE mode') ) poe_type = CSVChoiceField( choices=InterfacePoETypeChoices, required=False, - help_text='PoE type' + help_text=_('PoE type') ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, - help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') ) vrf = CSVModelChoiceField( queryset=VRF.objects.all(), required=False, to_field_name='rd', - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) rf_role = CSVChoiceField( choices=WirelessRoleChoices, required=False, - help_text='Wireless role (AP/station)' + help_text=_('Wireless role (AP/station)') ) class Meta: @@ -675,7 +717,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm): fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', - 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -699,7 +741,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm): return self.cleaned_data['enabled'] -class FrontPortCSVForm(NetBoxModelCSVForm): +class FrontPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -707,28 +749,28 @@ class FrontPortCSVForm(NetBoxModelCSVForm): rear_port = CSVModelChoiceField( queryset=RearPort.objects.all(), to_field_name='name', - help_text='Corresponding rear port' + help_text=_('Corresponding rear port') ) type = CSVChoiceField( choices=PortTypeChoices, - help_text='Physical medium classification' + help_text=_('Physical medium classification') ) class Meta: model = FrontPort fields = ( 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', - 'description', + 'description', 'tags' ) help_texts = { - 'rear_port_position': 'Mapped position on corresponding rear port', + 'rear_port_position': _('Mapped position on corresponding rear port'), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Limit RearPort choices to those belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -747,25 +789,25 @@ class FrontPortCSVForm(NetBoxModelCSVForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class RearPortCSVForm(NetBoxModelCSVForm): +class RearPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) type = CSVChoiceField( - help_text='Physical medium classification', + help_text=_('Physical medium classification'), choices=PortTypeChoices, ) class Meta: model = RearPort - fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description') + fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags') help_texts = { - 'positions': 'Number of front ports which may be mapped' + 'positions': _('Number of front ports which may be mapped') } -class ModuleBayCSVForm(NetBoxModelCSVForm): +class ModuleBayImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -773,10 +815,10 @@ class ModuleBayCSVForm(NetBoxModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'position', 'description') + fields = ('device', 'name', 'label', 'position', 'description', 'tags') -class DeviceBayCSVForm(NetBoxModelCSVForm): +class DeviceBayImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -785,21 +827,21 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Child device installed within this bay', + help_text=_('Child device installed within this bay'), error_messages={ - 'invalid_choice': 'Child device not found.', + 'invalid_choice': _('Child device not found.'), } ) class Meta: model = DeviceBay - fields = ('device', 'name', 'label', 'installed_device', 'description') + fields = ('device', 'name', 'label', 'installed_device', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Limit installed device choices to devices of the correct type and location - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -822,7 +864,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): self.fields['installed_device'].queryset = Interface.objects.none() -class InventoryItemCSVForm(NetBoxModelCSVForm): +class InventoryItemImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -841,14 +883,14 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), to_field_name='name', required=False, - help_text='Parent inventory item' + help_text=_('Parent inventory item') ) class Meta: model = InventoryItem fields = ( 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags' ) def __init__(self, *args, **kwargs): @@ -871,14 +913,14 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): # Device component roles # -class InventoryItemRoleCSVForm(NetBoxModelCSVForm): +class InventoryItemRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = InventoryItemRole fields = ('name', 'slug', 'color', 'description') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } @@ -886,68 +928,68 @@ class InventoryItemRoleCSVForm(NetBoxModelCSVForm): # Cables # -class CableCSVForm(NetBoxModelCSVForm): +class CableImportForm(NetBoxModelImportForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side A device' + help_text=_('Side A device') ) side_a_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side A type' + help_text=_('Side A type') ) side_a_name = forms.CharField( - help_text='Side A component name' + help_text=_('Side A component name') ) # Termination B side_b_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side B device' + help_text=_('Side B device') ) side_b_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side B type' + help_text=_('Side B type') ) side_b_name = forms.CharField( - help_text='Side B component name' + help_text=_('Side B component name') ) # Cable attributes status = CSVChoiceField( choices=LinkStatusChoices, required=False, - help_text='Connection status' + help_text=_('Connection status') ) type = CSVChoiceField( choices=CableTypeChoices, required=False, - help_text='Physical medium classification' + help_text=_('Physical medium classification') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) length_unit = CSVChoiceField( choices=CableLengthUnitChoices, required=False, - help_text='Length unit' + help_text=_('Length unit') ) class Meta: model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } def _clean_side(self, side): @@ -991,28 +1033,28 @@ class CableCSVForm(NetBoxModelCSVForm): # Virtual chassis # -class VirtualChassisCSVForm(NetBoxModelCSVForm): +class VirtualChassisImportForm(NetBoxModelImportForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', required=False, - help_text='Master device' + help_text=_('Master device') ) class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master') + fields = ('name', 'domain', 'master', 'description', 'comments', 'tags') # # Power # -class PowerPanelCSVForm(NetBoxModelCSVForm): +class PowerPanelImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site' + help_text=_('Name of parent site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), @@ -1022,7 +1064,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPanel - fields = ('site', 'location', 'name') + fields = ('site', 'location', 'name', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1034,51 +1076,51 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerFeedCSVForm(NetBoxModelCSVForm): +class PowerFeedImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) power_panel = CSVModelChoiceField( queryset=PowerPanel.objects.all(), to_field_name='name', - help_text='Upstream power panel' + help_text=_('Upstream power panel') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's location (if any)" + help_text=_("Rack's location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text='Rack' + help_text=_('Rack') ) status = CSVChoiceField( choices=PowerFeedStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) type = CSVChoiceField( choices=PowerFeedTypeChoices, - help_text='Primary or redundant' + help_text=_('Primary or redundant') ) supply = CSVChoiceField( choices=PowerFeedSupplyChoices, - help_text='Supply type (AC/DC)' + help_text=_('Supply type (AC/DC)') ) phase = CSVChoiceField( choices=PowerFeedPhaseChoices, - help_text='Single or three-phase' + help_text=_('Single or three-phase') ) class Meta: model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'comments', + 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -1100,3 +1142,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + + +class VirtualDeviceContextImportForm(NetBoxModelImportForm): + + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Assigned role' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + fields = [ + 'name', 'device', 'status', 'tenant', 'identifier', 'comments', + ] + model = VirtualDeviceContext + help_texts = {} diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index bfe09ab71..d479916d9 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * @@ -13,13 +14,13 @@ class InterfaceCommonForm(forms.Form): mac_address = forms.CharField( empty_value=None, required=False, - label='MAC address' + label=_('MAC address') ) mtu = forms.IntegerField( required=False, min_value=INTERFACE_MTU_MIN, max_value=INTERFACE_MTU_MAX, - label='MTU' + label=_('MTU') ) def clean(self): @@ -51,14 +52,15 @@ class InterfaceCommonForm(forms.Form): class ModuleCommonForm(forms.Form): + def clean(self): super().clean() replicate_components = self.cleaned_data.get("replicate_components") adopt_components = self.cleaned_data.get("adopt_components") - device = self.cleaned_data['device'] - module_type = self.cleaned_data['module_type'] - module_bay = self.cleaned_data['module_bay'] + device = self.cleaned_data.get('device') + module_type = self.cleaned_data.get('module_type') + module_bay = self.cleaned_data.get('module_bay') if adopt_components: self.instance._adopt_components = True diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 5e3948baa..ba5e51c41 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,9 +1,10 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField -from .models import CableForm +from .model_forms import CableForm def get_cable_form(a_type, b_type): @@ -16,7 +17,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField( queryset=Region.objects.all(), - label='Region', + label=_('Region'), required=False, initial_params={ 'sites': f'$termination_{cable_end}_site' @@ -24,7 +25,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), - label='Site group', + label=_('Site group'), required=False, initial_params={ 'sites': f'$termination_{cable_end}_site' @@ -32,7 +33,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField( queryset=Site.objects.all(), - label='Site', + label=_('Site'), required=False, query_params={ 'region_id': f'$termination_{cable_end}_region', @@ -41,7 +42,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField( queryset=Location.objects.all(), - label='Location', + label=_('Location'), required=False, null_option='None', query_params={ @@ -54,7 +55,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField( queryset=Rack.objects.all(), - label='Rack', + label=_('Rack'), required=False, null_option='None', initial_params={ @@ -67,7 +68,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device', + label=_('Device'), required=False, initial_params={ f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations' @@ -93,7 +94,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - label='Power Panel', + label=_('Power Panel'), required=False, initial_params={ 'powerfeeds__in': f'${cable_end}_terminations' @@ -105,7 +106,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), - label='Power Feed', + label=_('Power Feed'), disabled_indicator='_occupied', query_params={ 'power_panel_id': f'$termination_{cable_end}_powerpanel', @@ -117,7 +118,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField( queryset=Provider.objects.all(), - label='Provider', + label=_('Provider'), initial_params={ 'circuits': f'$termination_{cable_end}_circuit' }, @@ -125,7 +126,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField( queryset=Circuit.objects.all(), - label='Circuit', + label=_('Circuit'), initial_params={ 'terminations__in': f'${cable_end}_terminations' }, @@ -136,7 +137,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), - label='Side', + label=_('Side'), disabled_indicator='_occupied', query_params={ 'circuit_id': f'$termination_{cable_end}_circuit', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 96b0d1319..815f0586a 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -50,6 +50,7 @@ __all__ = ( 'SiteFilterForm', 'SiteGroupFilterForm', 'VirtualChassisFilterForm', + 'VirtualDeviceContextFilterForm' ) @@ -116,7 +117,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter_id', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -130,7 +131,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter_id', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -144,7 +145,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -174,7 +175,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -222,12 +223,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), + ('Weight', ('weight', 'max_weight', 'weight_unit')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -281,6 +283,18 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte required=False ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False, + min_value=1 + ) + max_weight = forms.IntegerField( + required=False, + min_value=1 + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class RackElevationFilterForm(RackFilterForm): @@ -298,7 +312,7 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('User', ('user_id',)), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -354,7 +368,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -363,13 +377,14 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -465,17 +480,25 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('manufacturer_id', 'part_number')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -529,6 +552,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class DeviceRoleFilterForm(NetBoxModelFilterSetForm): @@ -554,7 +584,7 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), @@ -704,11 +734,41 @@ class DeviceFilterForm( tag = TagFilterField(model) +class VirtualDeviceContextFilterForm( + TenancyFilterForm, + NetBoxModelFilterSetForm +): + model = VirtualDeviceContext + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + ('Attributes', ('device', 'status', 'has_primary_ip')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) + device = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + fetch_trigger='open' + ) + status = MultipleChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices) + ) + has_primary_ip = forms.NullBooleanField( + required=False, + label='Has a primary IP', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'tag')), - ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), + (None, ('q', 'filter_id', 'tag')), + ('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -725,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo label=_('Type'), fetch_trigger='open' ) + status = MultipleChoiceField( + choices=ModuleStatusChoices, + required=False + ) serial = forms.CharField( required=False ) @@ -737,7 +801,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -766,7 +830,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -838,7 +902,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -876,7 +940,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ) @@ -978,7 +1042,7 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -997,7 +1061,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1016,7 +1080,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1031,7 +1095,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1046,14 +1110,23 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', + 'device_id', 'vdc_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) + vdc_id = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + query_params={ + 'device_id': '$device_id', + }, + label=_('Virtual Device Context') + ) kind = MultipleChoiceField( choices=InterfaceKindChoices, required=False @@ -1135,7 +1208,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1154,7 +1227,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1172,7 +1245,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'position')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1185,7 +1258,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1195,7 +1268,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/model_forms.py similarity index 86% rename from netbox/dcim/forms/models.py rename to netbox/dcim/forms/model_forms.py index 620806397..1614f4bae 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,7 +1,7 @@ from django import forms -from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField from dcim.choices import * @@ -62,6 +62,7 @@ __all__ = ( 'SiteGroupForm', 'VCMemberSelectForm', 'VirtualChassisForm', + 'VirtualDeviceContextForm' ) INTERFACE_MODE_HELP_TEXT = """ @@ -162,14 +163,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): 'time_zone': StaticSelect(), } help_texts = { - 'name': "Full name of the site", - 'facility': "Data center provider and facility (e.g. Equinix NY7)", - 'time_zone': "Local time zone", - 'description': "Short description (will appear in sites list)", - 'physical_address': "Physical location of the building (e.g. for GPS)", - 'shipping_address': "If different from the physical address", - 'latitude': "Latitude in decimal format (xx.yyyyyy)", - 'longitude': "Longitude in decimal format (xx.yyyyyy)" + 'name': _("Full name of the site"), + 'facility': _("Data center provider and facility (e.g. Equinix NY7)"), + 'time_zone': _("Local time zone"), + 'description': _("Short description (will appear in sites list)"), + 'physical_address': _("Physical location of the building (e.g. for GPS)"), + 'shipping_address': _("If different from the physical address"), + 'latitude': _("Latitude in decimal format (xx.yyyyyy)"), + 'longitude': _("Longitude in decimal format (xx.yyyyyy)") } @@ -278,19 +279,20 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'comments', 'tags', + 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ] help_texts = { - 'site': "The site at which the rack exists", - 'name': "Organizational rack name", - 'facility_id': "The unique rack ID assigned by the facility", - 'u_height': "Height in rack units", + 'site': _("The site at which the rack exists"), + 'name': _("Organizational rack name"), + 'facility_id': _("The unique rack ID assigned by the facility"), + 'u_height': _("Height in rack units"), } widgets = { 'status': StaticSelect(), 'type': StaticSelect(), 'width': StaticSelect(), 'outer_unit': StaticSelect(), + 'weight_unit': StaticSelect(), } @@ -333,7 +335,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ) units = NumericArrayField( base_field=forms.IntegerField(), - help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." + help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") ) user = forms.ModelChoiceField( queryset=User.objects.order_by( @@ -341,6 +343,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ), widget=StaticSelect() ) + comments = CommentField() fieldsets = ( ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), @@ -351,7 +354,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): model = RackReservation fields = [ 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'tags', + 'description', 'comments', 'tags', ] @@ -381,11 +384,9 @@ class DeviceTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', - )), + ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')), ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', )), ('Images', ('front_image', 'rear_image')), ) @@ -394,7 +395,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'front_image', 'rear_image', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -404,7 +405,8 @@ class DeviceTypeForm(NetBoxModelForm): }), 'rear_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS - }) + }), + 'weight_unit': StaticSelect(), } @@ -415,17 +417,20 @@ class ModuleTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Module Type', ( - 'manufacturer', 'model', 'part_number', 'tags', - )), + ('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')), + ('Weight', ('weight', 'weight_unit')) ) class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'comments', 'tags', + 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] + widgets = { + 'weight_unit': StaticSelect(), + } + class DeviceRoleForm(NetBoxModelForm): slug = SlugField() @@ -511,7 +516,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) position = forms.DecimalField( required=False, - help_text="The lowest-numbered unit occupied by the device", + help_text=_("The lowest-numbered unit occupied by the device"), widget=APISelect( api_url='/api/dcim/racks/{{rack}}/elevation/', attrs={ @@ -569,13 +574,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) vc_position = forms.IntegerField( required=False, - label='Position', - help_text="The position in the virtual chassis this device is identified by" + label=_('Position'), + help_text=_("The position in the virtual chassis this device is identified by") ) vc_priority = forms.IntegerField( required=False, - label='Priority', - help_text="The priority of the device in the virtual chassis" + label=_('Priority'), + help_text=_("The priority of the device in the virtual chassis") ) class Meta: @@ -584,13 +589,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', - 'comments', 'tags', 'local_context_data' + 'description', 'comments', 'tags', 'local_context_data' ] help_texts = { - 'device_role': "The function this device serves", - 'serial': "Chassis serial number", - 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " - "config context", + 'device_role': _("The function this device serves"), + 'serial': _("Chassis serial number"), + 'local_context_data': _("Local config context data overwrites all source contexts in the final rendered " + "config context"), } widgets = { 'face': StaticSelect(), @@ -687,18 +692,18 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): replicate_components = forms.BooleanField( required=False, initial=True, - help_text="Automatically populate components associated with this module type" + help_text=_("Automatically populate components associated with this module type") ) adopt_components = forms.BooleanField( required=False, initial=False, - help_text="Adopt already existing components" + help_text=_("Adopt already existing components") ) fieldsets = ( ('Module', ( - 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags', )), ('Hardware', ( 'serial', 'asset_tag', 'replicate_components', 'adopt_components', @@ -708,8 +713,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): class Meta: model = Module fields = [ - 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'adopt_components', 'comments', + 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags', + 'replicate_components', 'adopt_components', 'description', 'comments', ] def __init__(self, *args, **kwargs): @@ -724,11 +729,13 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): class CableForm(TenancyForm, NetBoxModelForm): + comments = CommentField() class Meta: model = Cable fields = [ - 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', + 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, @@ -771,15 +778,16 @@ class PowerPanelForm(NetBoxModelForm): 'site_id': '$site' } ) + comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')), ) class Meta: model = PowerPanel fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags', ] @@ -837,15 +845,16 @@ class PowerFeedForm(NetBoxModelForm): fieldsets = ( ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) class Meta: model = PowerFeed fields = [ - 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', - 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', + 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', + 'tags', ] widgets = { 'status': StaticSelect(), @@ -864,11 +873,12 @@ class VirtualChassisForm(NetBoxModelForm): queryset=Device.objects.all(), required=False, ) + comments = CommentField() class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'master', 'tags', + 'name', 'domain', 'master', 'description', 'comments', 'tags', ] widgets = { 'master': SelectWithPK(), @@ -1316,10 +1326,18 @@ class PowerOutletForm(ModularDeviceComponentForm): class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): + vdcs = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface', + label=_('Parent interface'), query_params={ 'device_id': '$device', } @@ -1327,7 +1345,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): bridge = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Bridged interface', + label=_('Bridged interface'), query_params={ 'device_id': '$device', } @@ -1335,7 +1353,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='LAG interface', + label=_('LAG interface'), query_params={ 'device_id': '$device', 'type': 'lag', @@ -1344,12 +1362,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): wireless_lan_group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, - label='Wireless LAN group' + label=_('Wireless LAN group') ) wireless_lans = DynamicModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), required=False, - label='Wireless LANs', + label=_('Wireless LANs'), query_params={ 'group_id': '$wireless_lan_group', } @@ -1357,12 +1375,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', + label=_('Untagged VLAN'), query_params={ 'group_id': '$vlan_group', 'available_on_device': '$device', @@ -1371,7 +1389,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', + label=_('Tagged VLANs'), query_params={ 'group_id': '$vlan_group', 'available_on_device': '$device', @@ -1380,19 +1398,18 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) - wwn = forms.CharField( empty_value=None, required=False, - label='WWN' + label=_('WWN') ) fieldsets = ( ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), - ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('PoE', ('poe_mode', 'poe_type')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), @@ -1404,7 +1421,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class Meta: model = Interface fields = [ - 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', @@ -1424,8 +1441,8 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): } help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, - 'rf_channel_frequency': "Populated by selected channel (if set)", - 'rf_channel_width': "Populated by selected channel (if set)", + 'rf_channel_frequency': _("Populated by selected channel (if set)"), + 'rf_channel_width': _("Populated by selected channel (if set)"), } @@ -1499,8 +1516,8 @@ class DeviceBayForm(DeviceComponentForm): class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), - label='Child Device', - help_text="Child devices must first be created and assigned to the site/rack of the parent device.", + label=_('Child Device'), + help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."), widget=StaticSelect(), ) @@ -1581,3 +1598,90 @@ class InventoryItemRoleForm(NetBoxModelForm): fields = [ 'name', 'slug', 'color', 'description', 'tags', ] + + +class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site', + 'location_id': '$location', + 'rack_id': '$rack', + } + ) + primary_ip4 = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label='Primary IPv4', + required=False, + query_params={ + 'device_id': '$device', + 'family': '4', + } + ) + primary_ip6 = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label='Primary IPv6', + required=False, + query_params={ + 'device_id': '$device', + 'family': '6', + } + ) + + fieldsets = ( + ('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), + ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')) + ) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier', + 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags' + ] + widgets = { + 'status': StaticSelect(), + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index a03597db1..6de193043 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,9 +1,10 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.models import * from netbox.forms import NetBoxModelForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField -from . import models as model_forms +from . import model_forms __all__ = ( 'ComponentCreateForm', @@ -39,7 +40,7 @@ class ComponentCreateForm(forms.Form): name = ExpandableNameField() label = ExpandableNameField( required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by @@ -97,8 +98,8 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): rear_port = forms.MultipleChoiceField( choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', + label=_('Rear ports'), + help_text=_('Select one rear port assignment for each front port being created.'), ) # Override fieldsets from FrontPortTemplateForm to omit rear_port_position @@ -166,9 +167,9 @@ class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemp class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm): position = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) replication_fields = ('name', 'label', 'position') @@ -226,8 +227,8 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): rear_port = forms.MultipleChoiceField( choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', + label=_('Rear ports'), + help_text=_('Select one rear port assignment for each front port being created.'), ) # Override fieldsets from FrontPortForm to omit rear_port_position @@ -290,9 +291,9 @@ class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm): class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm): position = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) replication_fields = ('name', 'label', 'position') @@ -352,7 +353,7 @@ class VirtualChassisCreateForm(NetBoxModelForm): initial_position = forms.IntegerField( initial=1, required=False, - help_text='Position of the first member device. Increases by one for each additional member.' + help_text=_('Position of the first member device. Increases by one for each additional member.') ) class Meta: diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 023aba8f1..dfa1a4c6a 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.models import * @@ -8,43 +9,16 @@ __all__ = ( 'ConsolePortTemplateImportForm', 'ConsoleServerPortTemplateImportForm', 'DeviceBayTemplateImportForm', - 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', - 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', ) -class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name' - ) - - class Meta: - model = DeviceType - fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'comments', - ] - - -class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name' - ) - - class Meta: - model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'comments'] - - # # Component template import forms # @@ -115,12 +89,12 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): poe_mode = forms.ChoiceField( choices=InterfacePoEModeChoices, required=False, - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=InterfacePoETypeChoices, required=False, - label='PoE type' + label=_('PoE type') ) class Meta: diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py new file mode 100644 index 000000000..f3b8b696b --- /dev/null +++ b/netbox/dcim/graphql/gfk_mixins.py @@ -0,0 +1,169 @@ +import graphene +from circuits.graphql.types import CircuitTerminationType +from circuits.models import CircuitTermination +from dcim.graphql.types import ( + ConsolePortTemplateType, + ConsolePortType, + ConsoleServerPortTemplateType, + ConsoleServerPortType, + FrontPortTemplateType, + FrontPortType, + InterfaceTemplateType, + InterfaceType, + PowerFeedType, + PowerOutletTemplateType, + PowerOutletType, + PowerPortTemplateType, + PowerPortType, + RearPortTemplateType, + RearPortType, +) +from dcim.models import ( + ConsolePort, + ConsolePortTemplate, + ConsoleServerPort, + ConsoleServerPortTemplate, + FrontPort, + FrontPortTemplate, + Interface, + InterfaceTemplate, + PowerFeed, + PowerOutlet, + PowerOutletTemplate, + PowerPort, + PowerPortTemplate, + RearPort, + RearPortTemplate, +) + + +class LinkPeerType(graphene.Union): + class Meta: + types = ( + CircuitTerminationType, + ConsolePortType, + ConsoleServerPortType, + FrontPortType, + InterfaceType, + PowerFeedType, + PowerOutletType, + PowerPortType, + RearPortType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == CircuitTermination: + return CircuitTerminationType + if type(instance) == ConsolePortType: + return ConsolePortType + if type(instance) == ConsoleServerPort: + return ConsoleServerPortType + if type(instance) == FrontPort: + return FrontPortType + if type(instance) == Interface: + return InterfaceType + if type(instance) == PowerFeed: + return PowerFeedType + if type(instance) == PowerOutlet: + return PowerOutletType + if type(instance) == PowerPort: + return PowerPortType + if type(instance) == RearPort: + return RearPortType + + +class CableTerminationTerminationType(graphene.Union): + class Meta: + types = ( + CircuitTerminationType, + ConsolePortType, + ConsoleServerPortType, + FrontPortType, + InterfaceType, + PowerFeedType, + PowerOutletType, + PowerPortType, + RearPortType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == CircuitTermination: + return CircuitTerminationType + if type(instance) == ConsolePortType: + return ConsolePortType + if type(instance) == ConsoleServerPort: + return ConsoleServerPortType + if type(instance) == FrontPort: + return FrontPortType + if type(instance) == Interface: + return InterfaceType + if type(instance) == PowerFeed: + return PowerFeedType + if type(instance) == PowerOutlet: + return PowerOutletType + if type(instance) == PowerPort: + return PowerPortType + if type(instance) == RearPort: + return RearPortType + + +class InventoryItemTemplateComponentType(graphene.Union): + class Meta: + types = ( + ConsolePortTemplateType, + ConsoleServerPortTemplateType, + FrontPortTemplateType, + InterfaceTemplateType, + PowerOutletTemplateType, + PowerPortTemplateType, + RearPortTemplateType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == ConsolePortTemplate: + return ConsolePortTemplateType + if type(instance) == ConsoleServerPortTemplate: + return ConsoleServerPortTemplateType + if type(instance) == FrontPortTemplate: + return FrontPortTemplateType + if type(instance) == InterfaceTemplate: + return InterfaceTemplateType + if type(instance) == PowerOutletTemplate: + return PowerOutletTemplateType + if type(instance) == PowerPortTemplate: + return PowerPortTemplateType + if type(instance) == RearPortTemplate: + return RearPortTemplateType + + +class InventoryItemComponentType(graphene.Union): + class Meta: + types = ( + ConsolePortType, + ConsoleServerPortType, + FrontPortType, + InterfaceType, + PowerOutletType, + PowerPortType, + RearPortType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == ConsolePort: + return ConsolePortType + if type(instance) == ConsoleServerPort: + return ConsoleServerPortType + if type(instance) == FrontPort: + return FrontPortType + if type(instance) == Interface: + return InterfaceType + if type(instance) == PowerOutlet: + return PowerOutletType + if type(instance) == PowerPort: + return PowerPortType + if type(instance) == RearPort: + return RearPortType diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index d8488aa5f..133d6259f 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -1,5 +1,12 @@ +import graphene + + class CabledObjectMixin: + link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') def resolve_cable_end(self, info): # Handle empty values return self.cable_end or None + + def resolve_link_peers(self, info): + return self.link_peers diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 1d5b6a580..eba311420 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -2,6 +2,7 @@ import graphene from netbox.graphql.fields import ObjectField, ObjectListField from .types import * +from .types import VirtualDeviceContextType class DCIMQuery(graphene.ObjectType): @@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType): virtual_chassis = ObjectField(VirtualChassisType) virtual_chassis_list = ObjectListField(VirtualChassisType) + + virtual_device_context = ObjectField(VirtualDeviceContextType) + virtual_device_context_list = ObjectListField(VirtualDeviceContextType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 52a98278a..41f0092f9 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -2,7 +2,7 @@ import graphene from dcim import filtersets, models from extras.graphql.mixins import ( - ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt @@ -87,6 +87,8 @@ class ComponentTemplateObjectType( # class CableType(NetBoxObjectType): + a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') + b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') class Meta: model = models.Cable @@ -99,12 +101,19 @@ class CableType(NetBoxObjectType): def resolve_length_unit(self, info): return self.length_unit or None + def resolve_a_terminations(self, info): + return self.a_terminations + + def resolve_b_terminations(self, info): + return self.b_terminations + class CableTerminationType(NetBoxObjectType): + termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType') class Meta: model = models.CableTermination - fields = '__all__' + exclude = ('termination_type', 'termination_id') filterset_class = filtersets.CableTerminationFilterSet @@ -152,7 +161,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType): +class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): class Meta: model = models.Device @@ -183,10 +192,11 @@ class DeviceBayTemplateType(ComponentTemplateObjectType): class InventoryItemTemplateType(ComponentTemplateObjectType): + component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType') class Meta: model = models.InventoryItemTemplate - fields = '__all__' + exclude = ('component_type', 'component_id') filterset_class = filtersets.InventoryItemTemplateFilterSet @@ -211,6 +221,9 @@ class DeviceTypeType(NetBoxObjectType): def resolve_airflow(self, info): return self.airflow or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class FrontPortType(ComponentObjectType, CabledObjectMixin): @@ -266,10 +279,11 @@ class InterfaceTemplateType(ComponentTemplateObjectType): class InventoryItemType(ComponentObjectType): + component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType') class Meta: model = models.InventoryItem - fields = '__all__' + exclude = ('component_type', 'component_id') filterset_class = filtersets.InventoryItemFilterSet @@ -281,7 +295,7 @@ class InventoryItemRoleType(OrganizationalObjectType): filterset_class = filtersets.InventoryItemRoleFilterSet -class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType): +class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): class Meta: model = models.Location @@ -289,7 +303,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectT filterset_class = filtersets.LocationFilterSet -class ManufacturerType(OrganizationalObjectType): +class ManufacturerType(OrganizationalObjectType, ContactsMixin): class Meta: model = models.Manufacturer @@ -328,6 +342,9 @@ class ModuleTypeType(NetBoxObjectType): fields = '__all__' filterset_class = filtersets.ModuleTypeFilterSet + def resolve_weight_unit(self, info): + return self.weight_unit or None + class PlatformType(OrganizationalObjectType): @@ -373,7 +390,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType): return self.type or None -class PowerPanelType(NetBoxObjectType): +class PowerPanelType(NetBoxObjectType, ContactsMixin): class Meta: model = models.PowerPanel @@ -403,7 +420,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): +class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): class Meta: model = models.Rack @@ -416,6 +433,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): def resolve_outer_unit(self, info): return self.outer_unit or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class RackReservationType(NetBoxObjectType): @@ -449,7 +469,7 @@ class RearPortTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.RearPortTemplateFilterSet -class RegionType(VLANGroupsMixin, OrganizationalObjectType): +class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): class Meta: model = models.Region @@ -457,7 +477,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): filterset_class = filtersets.RegionFilterSet -class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): +class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): asn = graphene.Field(BigInt) class Meta: @@ -466,7 +486,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): filterset_class = filtersets.SiteFilterSet -class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType): +class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): class Meta: model = models.SiteGroup @@ -480,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType): model = models.VirtualChassis fields = '__all__' filterset_class = filtersets.VirtualChassisFilterSet + + +class VirtualDeviceContextType(NetBoxObjectType): + + class Meta: + model = models.VirtualDeviceContext + fields = '__all__' + filterset_class = filtersets.VirtualDeviceContextFilterSet diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index 374d3bf45..3d7156e17 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -1,6 +1,6 @@ import dcim.fields import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('termination_a_id', models.PositiveIntegerField()), ('termination_b_id', models.PositiveIntegerField()), @@ -60,7 +60,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -96,7 +96,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -132,7 +132,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('name', models.CharField(blank=True, max_length=64, null=True)), @@ -155,7 +155,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -186,7 +186,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -195,7 +195,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -203,7 +203,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -224,7 +224,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -261,7 +261,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('label', models.CharField(blank=True, max_length=64)), @@ -302,7 +302,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -326,7 +326,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -345,14 +345,14 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -360,7 +360,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -369,7 +369,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -377,7 +377,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)), ('mark_connected', models.BooleanField(default=False)), @@ -401,7 +401,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -438,7 +438,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ], @@ -451,7 +451,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -490,7 +490,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -516,7 +516,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), ('description', models.CharField(max_length=200)), @@ -530,7 +530,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -538,7 +538,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -546,7 +546,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -583,7 +583,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -602,7 +602,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -630,7 +630,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -649,7 +649,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('domain', models.CharField(blank=True, max_length=30)), diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py index 11324fc58..821cf6119 100644 --- a/netbox/dcim/migrations/0146_modules.py +++ b/netbox/dcim/migrations/0146_modules.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -107,7 +107,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('part_number', models.CharField(blank=True, max_length=50)), @@ -125,7 +125,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -145,7 +145,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('serial', models.CharField(blank=True, max_length=50)), diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index f5e1f23f5..4b6c27450 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.AddField( diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py new file mode 100644 index 000000000..d52dbb6c9 --- /dev/null +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -0,0 +1,332 @@ +from django.db import migrations, models +import django.db.models.functions.text + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0161_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='cabletermination', + name='dcim_cable_termination_unique_termination', + ), + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_name', + ), + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_slug', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_name', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_slug', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_name', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_slug', + ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitem', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitemtemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='moduletype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerfeed', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerpanel', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cabletermination_unique_termination'), + ), + migrations.AddConstraint( + model_name='consoleport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleserverport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleserverporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleserverporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), condition=models.Q(('tenant__isnull', True)), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('rack', 'position', 'face'), name='dcim_device_unique_rack_position_face'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'), + ), + migrations.AddConstraint( + model_name='devicebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_devicebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='devicebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_devicebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_devicetype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'slug'), name='dcim_devicetype_unique_manufacturer_slug'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_frontport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontport_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_frontporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_frontporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontporttemplate_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='interface', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_interface_unique_device_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_interfacetemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_interfacetemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='inventoryitem', + constraint=models.UniqueConstraint(fields=('device', 'parent', 'name'), name='dcim_inventoryitem_unique_device_parent_name'), + ), + migrations.AddConstraint( + model_name='inventoryitemtemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'parent', 'name'), name='dcim_inventoryitemtemplate_unique_device_type_parent_name'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='modulebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_modulebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='modulebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_modulebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='moduletype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_moduletype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='powerfeed', + constraint=models.UniqueConstraint(fields=('power_panel', 'name'), name='dcim_powerfeed_unique_power_panel_name'), + ), + migrations.AddConstraint( + model_name='poweroutlet', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_poweroutlet_unique_device_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_poweroutlettemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_poweroutlettemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='powerpanel', + constraint=models.UniqueConstraint(fields=('site', 'name'), name='dcim_powerpanel_unique_site_name'), + ), + migrations.AddConstraint( + model_name='powerport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_powerport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_powerporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_powerporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'name'), name='dcim_rack_unique_location_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'facility_id'), name='dcim_rack_unique_location_facility_id'), + ), + migrations.AddConstraint( + model_name='rearport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_rearport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_rearporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_rearporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_region_slug', violation_error_message='A top-level region with this slug already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_sitegroup_name', violation_error_message='A top-level site group with this name already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'), + ), + ] diff --git a/netbox/dcim/migrations/0163_weight_fields.py b/netbox/dcim/migrations/0163_weight_fields.py new file mode 100644 index 000000000..ddcc01164 --- /dev/null +++ b/netbox/dcim/migrations/0163_weight_fields.py @@ -0,0 +1,72 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0162_unique_constraints'), + ] + + operations = [ + + # Device types + migrations.AddField( + model_name='devicetype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='devicetype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + + # Module types + migrations.AddField( + model_name='moduletype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='moduletype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + + # Racks + migrations.AddField( + model_name='rack', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='rack', + name='max_weight', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='rack', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='_abs_max_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0164_rack_mounting_depth.py b/netbox/dcim/migrations/0164_rack_mounting_depth.py new file mode 100644 index 000000000..96836e0d6 --- /dev/null +++ b/netbox/dcim/migrations/0164_rack_mounting_depth.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.1 on 2022-10-27 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0163_weight_fields'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='mounting_depth', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0165_standardize_description_comments.py b/netbox/dcim/migrations/0165_standardize_description_comments.py new file mode 100644 index 000000000..f17f1d321 --- /dev/null +++ b/netbox/dcim/migrations/0165_standardize_description_comments.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0164_rack_mounting_depth'), + ] + + operations = [ + migrations.AddField( + model_name='cable', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='cable', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='device', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicetype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='module', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='moduletype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerfeed', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerpanel', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='powerpanel', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rack', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rackreservation', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py new file mode 100644 index 000000000..05becbdc6 --- /dev/null +++ b/netbox/dcim/migrations/0166_virtualdevicecontext.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.2 on 2022-11-10 16:56 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0063_standardize_description_comments'), + ('extras', '0082_savedfilter'), + ('tenancy', '0009_standardize_description_comments'), + ('dcim', '0165_standardize_description_comments'), + ] + + operations = [ + migrations.CreateModel( + name='VirtualDeviceContext', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=64)), + ('status', models.CharField(max_length=50)), + ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')), + ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='interface', + name='vdcs', + field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifier'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_device_name'), + ), + ] diff --git a/netbox/dcim/migrations/0167_module_status.py b/netbox/dcim/migrations/0167_module_status.py new file mode 100644 index 000000000..c048b4bd8 --- /dev/null +++ b/netbox/dcim/migrations/0167_module_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-12-09 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0166_virtualdevicecontext'), + ] + + operations = [ + migrations.AddField( + model_name='module', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d00cfd4e6..fc9d2d7a1 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,8 +12,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.fields import PathField -from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import NetBoxModel +from dcim.utils import decompile_path_node, object_to_path_node +from netbox.models import PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -34,7 +34,7 @@ trace_paths = Signal() # Cables # -class Cable(NetBoxModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ @@ -269,7 +269,7 @@ class CableTermination(models.Model): constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), - name='dcim_cable_termination_unique_termination' + name='%(app_label)s_%(class)s_unique_termination' ), ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b7079d375..3b136987d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.utils.translation import gettext as _ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * @@ -52,7 +53,7 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): label = models.CharField( max_length=64, blank=True, - help_text="Physical label" + help_text=_("Physical label") ) description = models.CharField( max_length=200, @@ -61,6 +62,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): class Meta: abstract = True + ordering = ('device_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + ) def __str__(self): if self.label: @@ -100,6 +108,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True + ordering = ('device_type', 'module_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + ) def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -145,13 +164,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): component_model = ConsolePort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -181,13 +193,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): component_model = ConsoleServerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -218,24 +223,17 @@ class PowerPortTemplate(ModularComponentTemplateModel): blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) component_model = PowerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -286,18 +284,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel): max_length=50, choices=PowerOutletFeedLegChoices, blank=True, - help_text="Phase (for three-phase feeds)" + help_text=_("Phase (for three-phase feeds)") ) component_model = PowerOutlet - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def clean(self): super().clean() @@ -372,13 +363,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): component_model = Interface - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -428,12 +412,20 @@ class FrontPortTemplate(ModularComponentTemplateModel): component_model = FrontPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentTemplateModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def clean(self): @@ -507,13 +499,6 @@ class RearPortTemplate(ModularComponentTemplateModel): component_model = RearPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -542,15 +527,11 @@ class ModuleBayTemplate(ComponentTemplateModel): position = models.CharField( max_length=30, blank=True, - help_text='Identifier to reference when renaming installed components' + help_text=_('Identifier to reference when renaming installed components') ) component_model = ModuleBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -574,10 +555,6 @@ class DeviceBayTemplate(ComponentTemplateModel): """ component_model = DeviceBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -645,7 +622,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): max_length=50, verbose_name='Part ID', blank=True, - help_text='Manufacturer-assigned part identifier' + help_text=_('Manufacturer-assigned part identifier') ) objects = TreeManager() @@ -653,7 +630,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): class Meta: ordering = ('device_type__id', 'parent__id', '_name') - unique_together = ('device_type', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_parent_name' + ), + ) def instantiate(self, **kwargs): parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index dc1ae6e35..658423e52 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum from django.urls import reverse +from django.utils.translation import gettext as _ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * @@ -60,7 +61,7 @@ class ComponentModel(NetBoxModel): label = models.CharField( max_length=64, blank=True, - help_text="Physical label" + help_text=_("Physical label") ) description = models.CharField( max_length=200, @@ -69,6 +70,13 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True + ordering = ('device', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + ) def __str__(self): if self.label: @@ -99,7 +107,7 @@ class ModularComponentModel(ComponentModel): object_id_field='component_id' ) - class Meta: + class Meta(ComponentModel.Meta): abstract = True @@ -122,7 +130,7 @@ class CabledObjectModel(models.Model): ) mark_connected = models.BooleanField( default=False, - help_text="Treat as if a cable is connected" + help_text=_("Treat as if a cable is connected") ) cable_terminations = GenericRelation( @@ -254,21 +262,17 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=ConsolePortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) speed = models.PositiveIntegerField( choices=ConsolePortSpeedChoices, blank=True, null=True, - help_text='Port speed in bits per second' + help_text=_('Port speed in bits per second') ) clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) @@ -281,21 +285,17 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=ConsolePortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) speed = models.PositiveIntegerField( choices=ConsolePortSpeedChoices, blank=True, null=True, - help_text='Port speed in bits per second' + help_text=_('Port speed in bits per second') ) clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) @@ -312,27 +312,23 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerPortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) maximum_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:powerport', kwargs={'pk': self.pk}) @@ -425,7 +421,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerOutletTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) power_port = models.ForeignKey( to='dcim.PowerPort', @@ -438,15 +434,11 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerOutletFeedLegChoices, blank=True, - help_text="Phase (for three-phase feeds)" + help_text=_("Phase (for three-phase feeds)") ) clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) @@ -540,6 +532,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd max_length=100, blank=True ) + vdcs = models.ManyToManyField( + to='dcim.VirtualDeviceContext', + related_name='interfaces' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -555,7 +551,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd mgmt_only = models.BooleanField( default=False, verbose_name='Management only', - help_text='This interface is used only for out-of-band management' + help_text=_('This interface is used only for out-of-band management') ) speed = models.PositiveIntegerField( blank=True, @@ -572,7 +568,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd null=True, blank=True, verbose_name='WWN', - help_text='64-bit World Wide Name' + help_text=_('64-bit World Wide Name') ) rf_role = models.CharField( max_length=30, @@ -677,9 +673,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', ) - class Meta: + class Meta(ModularComponentModel.Meta): ordering = ('device', CollateAsChar('_name')) - unique_together = ('device', 'name') def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) @@ -895,11 +890,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel): clone_fields = ('device', 'type', 'color') - class Meta: - ordering = ('device', '_name') - unique_together = ( - ('device', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def get_absolute_url(self): @@ -944,10 +944,6 @@ class RearPort(ModularComponentModel, CabledObjectModel): ) clone_fields = ('device', 'type', 'color', 'positions') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:rearport', kwargs={'pk': self.pk}) @@ -955,12 +951,13 @@ class RearPort(ModularComponentModel, CabledObjectModel): super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts - frontport_count = self.frontports.count() - if self.positions < frontport_count: - raise ValidationError({ - "positions": f"The number of positions cannot be less than the number of mapped front ports " - f"({frontport_count})" - }) + if self.pk: + frontport_count = self.frontports.count() + if self.positions < frontport_count: + raise ValidationError({ + "positions": f"The number of positions cannot be less than the number of mapped front ports " + f"({frontport_count})" + }) # @@ -974,15 +971,11 @@ class ModuleBay(ComponentModel): position = models.CharField( max_length=30, blank=True, - help_text='Identifier to reference when renaming installed components' + help_text=_('Identifier to reference when renaming installed components') ) clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) @@ -1001,10 +994,6 @@ class DeviceBay(ComponentModel): clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:devicebay', kwargs={'pk': self.pk}) @@ -1041,27 +1030,9 @@ class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:inventoryitemrole', args=[self.pk]) @@ -1114,7 +1085,7 @@ class InventoryItem(MPTTModel, ComponentModel): max_length=50, verbose_name='Part ID', blank=True, - help_text='Manufacturer-assigned part identifier' + help_text=_('Manufacturer-assigned part identifier') ) serial = models.CharField( max_length=50, @@ -1127,11 +1098,11 @@ class InventoryItem(MPTTModel, ComponentModel): blank=True, null=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this item' + help_text=_('A unique tag used to identify this item') ) discovered = models.BooleanField( default=False, - help_text='This item was automatically discovered' + help_text=_('This item was automatically discovered') ) objects = TreeManager() @@ -1140,7 +1111,12 @@ class InventoryItem(MPTTModel, ComponentModel): class Meta: ordering = ('device__id', 'parent__id', '_name') - unique_together = ('device', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_parent_name' + ), + ) def get_absolute_url(self): return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4c542341e..53c6d12a7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,25 +1,29 @@ import decimal - import yaml -from django.apps import apps +from functools import cached_property + from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError +from django.db.models.functions import Lower +from django.db.models.signals import post_save from django.urls import reverse from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * +from .mixins import WeightMixin __all__ = ( @@ -31,6 +35,7 @@ __all__ = ( 'ModuleType', 'Platform', 'VirtualChassis', + 'VirtualDeviceContext', ) @@ -42,35 +47,16 @@ class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations contacts = GenericRelation( to='tenancy.ContactAssignment' ) - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(NetBoxModel): +class DeviceType(PrimaryModel, WeightMixin): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -99,7 +85,7 @@ class DeviceType(NetBoxModel): part_number = models.CharField( max_length=50, blank=True, - help_text='Discrete part number (optional)' + help_text=_('Discrete part number (optional)') ) u_height = models.DecimalField( max_digits=4, @@ -110,15 +96,15 @@ class DeviceType(NetBoxModel): is_full_depth = models.BooleanField( default=True, verbose_name='Is full depth', - help_text='Device consumes both front and rear rack faces' + help_text=_('Device consumes both front and rear rack faces') ) subdevice_role = models.CharField( max_length=50, choices=SubdeviceRoleChoices, blank=True, verbose_name='Parent/child status', - help_text='Parent devices house child devices in device bays. Leave blank ' - 'if this device type is neither a parent nor a child.' + help_text=_('Parent devices house child devices in device bays. Leave blank ' + 'if this device type is neither a parent nor a child.') ) airflow = models.CharField( max_length=50, @@ -133,20 +119,26 @@ class DeviceType(NetBoxModel): upload_to='devicetype-images', blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' + ) + prerequisite_models = ( + 'dcim.Manufacturer', ) class Meta: ordering = ['manufacturer', 'model'] - unique_together = [ - ['manufacturer', 'model'], - ['manufacturer', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + models.UniqueConstraint( + fields=('manufacturer', 'slug'), + name='%(app_label)s_%(class)s_unique_manufacturer_slug' + ), + ) def __str__(self): return self.model @@ -161,10 +153,6 @@ class DeviceType(NetBoxModel): self._original_front_image = self.front_image self._original_rear_image = self.rear_image - @classmethod - def get_prerequisite_models(cls): - return [Manufacturer, ] - def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) @@ -268,7 +256,7 @@ class DeviceType(NetBoxModel): if ( self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT - ) and self.devicebaytemplates.count(): + ) and self.pk and self.devicebaytemplates.count(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " "declassifying it as a parent device." @@ -308,7 +296,7 @@ class DeviceType(NetBoxModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(NetBoxModel): +class ModuleType(PrimaryModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -326,10 +314,7 @@ class ModuleType(NetBoxModel): part_number = models.CharField( max_length=50, blank=True, - help_text='Discrete part number (optional)' - ) - comments = models.TextField( - blank=True + help_text=_('Discrete part number (optional)') ) # Generic relations @@ -337,21 +322,23 @@ class ModuleType(NetBoxModel): to='extras.ImageAttachment' ) - clone_fields = ('manufacturer',) + clone_fields = ('manufacturer', 'weight', 'weight_unit',) + prerequisite_models = ( + 'dcim.Manufacturer', + ) class Meta: ordering = ('manufacturer', 'model') - unique_together = ( - ('manufacturer', 'model'), + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), ) def __str__(self): return self.model - @classmethod - def get_prerequisite_models(cls): - return [Manufacturer, ] - def get_absolute_url(self): return reverse('dcim:moduletype', args=[self.pk]) @@ -406,32 +393,14 @@ class DeviceRole(OrganizationalModel): color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to virtual machines as well. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) vm_role = models.BooleanField( default=True, verbose_name='VM Role', - help_text='Virtual machines may be assigned to this role' + help_text=_('Virtual machines may be assigned to this role') ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:devicerole', args=[self.pk]) @@ -443,50 +412,32 @@ class Platform(OrganizationalModel): NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, related_name='platforms', blank=True, null=True, - help_text='Optionally limit this platform to devices of a certain manufacturer' + help_text=_('Optionally limit this platform to devices of a certain manufacturer') ) napalm_driver = models.CharField( max_length=50, blank=True, verbose_name='NAPALM driver', - help_text='The name of the NAPALM driver to use when interacting with devices' + help_text=_('The name of the NAPALM driver to use when interacting with devices') ) napalm_args = models.JSONField( blank=True, null=True, verbose_name='NAPALM arguments', - help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' + help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)') ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:platform', args=[self.pk]) -class Device(NetBoxModel, ConfigContextModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -544,7 +495,7 @@ class Device(NetBoxModel, ConfigContextModel): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this device' + help_text=_('A unique tag used to identify this device') ) site = models.ForeignKey( to='dcim.Site', @@ -572,7 +523,7 @@ class Device(NetBoxModel, ConfigContextModel): null=True, validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', - help_text='The lowest-numbered unit occupied by the device' + help_text=_('The lowest-numbered unit occupied by the device') ) face = models.CharField( max_length=50, @@ -630,9 +581,6 @@ class Device(NetBoxModel, ConfigContextModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -648,13 +596,33 @@ class Device(NetBoxModel, ConfigContextModel): 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow', 'cluster', 'virtual_chassis', ) + prerequisite_models = ( + 'dcim.Site', + 'dcim.DeviceRole', + 'dcim.DeviceType', + ) class Meta: ordering = ('_name', 'pk') # Name may be null - unique_together = ( - ('site', 'tenant', 'name'), # See validate_unique below - ('rack', 'position', 'face'), - ('virtual_chassis', 'vc_position'), + constraints = ( + models.UniqueConstraint( + Lower('name'), 'site', 'tenant', + name='%(app_label)s_%(class)s_unique_name_site_tenant' + ), + models.UniqueConstraint( + Lower('name'), 'site', + name='%(app_label)s_%(class)s_unique_name_site', + condition=Q(tenant__isnull=True), + violation_error_message="Device name must be unique per site." + ), + models.UniqueConstraint( + fields=('rack', 'position', 'face'), + name='%(app_label)s_%(class)s_unique_rack_position_face' + ), + models.UniqueConstraint( + fields=('virtual_chassis', 'vc_position'), + name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position' + ), ) def __str__(self): @@ -672,30 +640,9 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ] - def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) - def validate_unique(self, exclude=None): - - # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary - # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation - # of the uniqueness constraint without manual intervention. - if self.name and hasattr(self, 'site') and self.tenant is None: - if Device.objects.exclude(pk=self.pk).filter( - name=self.name, - site=self.site, - tenant__isnull=True - ): - raise ValidationError({ - 'name': 'A device with this name already exists.' - }) - - super().validate_unique(exclude) - def clean(self): super().clean() @@ -821,6 +768,32 @@ class Device(NetBoxModel, ConfigContextModel): 'vc_position': "A device assigned to a virtual chassis must have its position defined." }) + def _instantiate_components(self, queryset, bulk_create=True): + """ + Instantiate components for the device from the specified component templates. + + Args: + bulk_create: If True, bulk_create() will be called to create all components in a single query + (default). Otherwise, save() will be called on each instance individually. + """ + components = [obj.instantiate(device=self) for obj in queryset] + if components and bulk_create: + model = components[0]._meta.model + model.objects.bulk_create(components) + # Manually send the post_save signal for each of the newly created components + for component in components: + post_save.send( + sender=model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + elif components: + for component in components: + component.save() + def save(self, *args, **kwargs): is_new = not bool(self.pk) @@ -830,38 +803,19 @@ class Device(NetBoxModel, ConfigContextModel): super().save(*args, **kwargs) - # If this is a new Device, instantiate all of the related components per the DeviceType definition + # If this is a new Device, instantiate all the related components per the DeviceType definition if is_new: - ConsolePort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] - ) - ConsoleServerPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] - ) - PowerPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] - ) - PowerOutlet.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] - ) - Interface.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] - ) - RearPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] - ) - FrontPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] - ) - ModuleBay.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] - ) - DeviceBay.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] - ) - # Avoid bulk_create to handle MPTT - for x in self.device_type.inventoryitemtemplates.all(): - x.instantiate(device=self).save() + self._instantiate_components(self.device_type.consoleporttemplates.all()) + self._instantiate_components(self.device_type.consoleserverporttemplates.all()) + self._instantiate_components(self.device_type.powerporttemplates.all()) + self._instantiate_components(self.device_type.poweroutlettemplates.all()) + self._instantiate_components(self.device_type.interfacetemplates.all()) + self._instantiate_components(self.device_type.rearporttemplates.all()) + self._instantiate_components(self.device_type.frontporttemplates.all()) + self._instantiate_components(self.device_type.modulebaytemplates.all()) + self._instantiate_components(self.device_type.devicebaytemplates.all()) + # Disable bulk_create to accommodate MPTT + self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) @@ -938,8 +892,20 @@ class Device(NetBoxModel, ConfigContextModel): def get_status_color(self): return DeviceStatusChoices.colors.get(self.status) + @cached_property + def total_weight(self): + total_weight = sum( + module.module_type._abs_weight + for module in Module.objects.filter(device=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self.device_type._abs_weight: + total_weight += self.device_type._abs_weight + return round(total_weight / 1000, 2) -class Module(NetBoxModel, ConfigContextModel): + +class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. @@ -959,6 +925,11 @@ class Module(NetBoxModel, ConfigContextModel): on_delete=models.PROTECT, related_name='instances' ) + status = models.CharField( + max_length=50, + choices=ModuleStatusChoices, + default=ModuleStatusChoices.STATUS_ACTIVE + ) serial = models.CharField( max_length=50, blank=True, @@ -970,13 +941,10 @@ class Module(NetBoxModel, ConfigContextModel): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this device' - ) - comments = models.TextField( - blank=True + help_text=_('A unique tag used to identify this device') ) - clone_fields = ('device', 'module_type') + clone_fields = ('device', 'module_type', 'status') class Meta: ordering = ('module_bay',) @@ -987,6 +955,9 @@ class Module(NetBoxModel, ConfigContextModel): def get_absolute_url(self): return reverse('dcim:module', args=[self.pk]) + def get_status_color(self): + return ModuleStatusChoices.colors.get(self.status) + def clean(self): super().clean() @@ -1023,7 +994,8 @@ class Module(NetBoxModel, ConfigContextModel): # Prefetch installed components installed_components = { - component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True) + component.name: component + for component in getattr(self.device, component_attribute).filter(module__isnull=True) } # Get the template for the module type. @@ -1045,14 +1017,36 @@ class Module(NetBoxModel, ConfigContextModel): create_instances.append(template_instance) component_model.objects.bulk_create(create_instances) - component_model.objects.bulk_update(update_instances, ['module']) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + + update_fields = ['module'] + component_model.objects.bulk_update(update_instances, update_fields) + # Emit the post_save signal for each updated object + for component in update_instances: + post_save.send( + sender=component_model, + instance=component, + created=False, + raw=False, + using='default', + update_fields=update_fields + ) # # Virtual chassis # -class VirtualChassis(NetBoxModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ @@ -1107,3 +1101,101 @@ class VirtualChassis(NetBoxModel): ) return super().delete(*args, **kwargs) + + +class VirtualDeviceContext(PrimaryModel): + device = models.ForeignKey( + to='Device', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + name = models.CharField( + max_length=64 + ) + status = models.CharField( + max_length=50, + choices=VirtualDeviceContextStatusChoices, + ) + identifier = models.PositiveSmallIntegerField( + help_text='Numeric identifier unique to the parent device', + blank=True, + null=True, + ) + primary_ip4 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv4' + ) + primary_ip6 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv6' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + ordering = ['name'] + constraints = ( + models.UniqueConstraint( + fields=('device', 'identifier',), + name='%(app_label)s_%(class)s_device_identifier' + ), + models.UniqueConstraint( + fields=('device', 'name',), + name='%(app_label)s_%(class)s_device_name' + ), + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk}) + + def get_status_color(self): + return VirtualDeviceContextStatusChoices.colors.get(self.status) + + @property + def primary_ip(self): + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: + return self.primary_ip4 + elif self.primary_ip6: + return self.primary_ip6 + elif self.primary_ip4: + return self.primary_ip4 + else: + return None + + def clean(self): + super().clean() + + # Validate primary IPv4/v6 assignment + for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)): + if not primary_ip: + continue + if primary_ip.family != family: + raise ValidationError({ + f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address." + }) + device_interfaces = self.device.vc_interfaces(if_master=False) + if primary_ip.assigned_object not in device_interfaces: + raise ValidationError({ + f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.') + }) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py new file mode 100644 index 000000000..486945b0f --- /dev/null +++ b/netbox/dcim/models/mixins.py @@ -0,0 +1,43 @@ +from django.core.exceptions import ValidationError +from django.db import models +from dcim.choices import * +from utilities.utils import to_grams + + +class WeightMixin(models.Model): + weight = models.DecimalField( + max_digits=8, + decimal_places=2, + blank=True, + null=True + ) + weight_unit = models.CharField( + max_length=50, + choices=WeightUnitChoices, + blank=True, + ) + # Stores the normalized weight (in grams) for database ordering + _abs_weight = models.PositiveBigIntegerField( + blank=True, + null=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + + # Store the given weight (if any) in grams for use in database ordering + if self.weight and self.weight_unit: + self._abs_weight = to_grams(self.weight, self.weight_unit) + else: + self._abs_weight = None + + super().save(*args, **kwargs) + + def clean(self): + super().clean() + + # Validate weight and weight_unit + if self.weight and not self.weight_unit: + raise ValidationError("Must specify a unit when setting a weight") diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 83eead67f..3377a9edb 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -1,14 +1,13 @@ -from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.choices import * -from dcim.constants import * from netbox.config import ConfigItem -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import CabledObjectModel, PathEndpoint @@ -22,7 +21,7 @@ __all__ = ( # Power # -class PowerPanel(NetBoxModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -48,17 +47,22 @@ class PowerPanel(NetBoxModel): to='extras.ImageAttachment' ) + prerequisite_models = ( + 'dcim.Site', + ) + class Meta: ordering = ['site', 'name'] - unique_together = ['site', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), + ) def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), ] - def get_absolute_url(self): return reverse('dcim:powerpanel', args=[self.pk]) @@ -72,7 +76,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): +class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -121,32 +125,33 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'), - help_text="Maximum permissible draw (percentage)" + help_text=_("Maximum permissible draw (percentage)") ) available_power = models.PositiveIntegerField( default=0, editable=False ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', ) + prerequisite_models = ( + 'dcim.PowerPanel', + ) class Meta: ordering = ['power_panel', 'name'] - unique_together = ['power_panel', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('power_panel', 'name'), + name='%(app_label)s_%(class)s_unique_power_panel_name' + ), + ) def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [PowerPanel, ] - def get_absolute_url(self): return reverse('dcim:powerfeed', args=[self.pk]) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index df4702501..03be2fdb3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,25 +1,26 @@ import decimal +from functools import cached_property -from django.apps import apps from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Count, Sum +from django.db.models import Count from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string, drange -from .device_components import PowerOutlet, PowerPort -from .devices import Device +from utilities.utils import array_to_string, drange, to_grams +from .device_components import PowerPort +from .devices import Device, Module +from .mixins import WeightMixin from .power import PowerFeed __all__ = ( @@ -37,33 +38,15 @@ class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(NetBoxModel): +class Rack(PrimaryModel, WeightMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -81,7 +64,7 @@ class Rack(NetBoxModel): blank=True, null=True, verbose_name='Facility ID', - help_text='Locally-assigned identifier' + help_text=_('Locally-assigned identifier') ) site = models.ForeignKey( to='dcim.Site', @@ -113,7 +96,7 @@ class Rack(NetBoxModel): related_name='racks', blank=True, null=True, - help_text='Functional role' + help_text=_('Functional role') ) serial = models.CharField( max_length=50, @@ -126,7 +109,7 @@ class Rack(NetBoxModel): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this rack' + help_text=_('A unique tag used to identify this rack') ) type = models.CharField( choices=RackTypeChoices, @@ -138,36 +121,51 @@ class Rack(NetBoxModel): choices=RackWidthChoices, default=RackWidthChoices.WIDTH_19IN, verbose_name='Width', - help_text='Rail-to-rail width' + help_text=_('Rail-to-rail width') ) u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)], - help_text='Height in rack units' + help_text=_('Height in rack units') ) desc_units = models.BooleanField( default=False, verbose_name='Descending units', - help_text='Units are numbered top-to-bottom' + help_text=_('Units are numbered top-to-bottom') ) outer_width = models.PositiveSmallIntegerField( blank=True, null=True, - help_text='Outer dimension of rack (width)' + help_text=_('Outer dimension of rack (width)') ) outer_depth = models.PositiveSmallIntegerField( blank=True, null=True, - help_text='Outer dimension of rack (depth)' + help_text=_('Outer dimension of rack (depth)') ) outer_unit = models.CharField( max_length=50, choices=RackDimensionUnitChoices, blank=True, ) - comments = models.TextField( - blank=True + max_weight = models.PositiveIntegerField( + blank=True, + null=True, + help_text=_('Maximum load capacity for the rack') + ) + # Stores the normalized max weight (in grams) for database ordering + _abs_max_weight = models.PositiveBigIntegerField( + blank=True, + null=True + ) + mounting_depth = models.PositiveSmallIntegerField( + blank=True, + null=True, + help_text=( + _('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the ' + 'distance between the front and rear rails.') + ) ) # Generic relations @@ -186,15 +184,24 @@ class Rack(NetBoxModel): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + ) + prerequisite_models = ( + 'dcim.Site', ) class Meta: ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique - unique_together = ( + constraints = ( # Name and facility_id must be unique *only* within a Location - ('location', 'name'), - ('location', 'facility_id'), + models.UniqueConstraint( + fields=('location', 'name'), + name='%(app_label)s_%(class)s_unique_location_name' + ), + models.UniqueConstraint( + fields=('location', 'facility_id'), + name='%(app_label)s_%(class)s_unique_location_facility_id' + ), ) def __str__(self): @@ -202,10 +209,6 @@ class Rack(NetBoxModel): return f'{self.name} ({self.facility_id})' return self.name - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), ] - def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) @@ -222,6 +225,10 @@ class Rack(NetBoxModel): elif self.outer_width is None and self.outer_depth is None: self.outer_unit = '' + # Validate max_weight and weight_unit + if self.max_weight and not self.weight_unit: + raise ValidationError("Must specify a unit when setting a maximum weight") + if self.pk: # Validate that Rack is tall enough to house the installed Devices top_device = Device.objects.filter( @@ -244,6 +251,16 @@ class Rack(NetBoxModel): 'location': f"Location must be from the same site, {self.site}." }) + def save(self, *args, **kwargs): + + # Store the given max weight (if any) in grams for use in database ordering + if self.max_weight and self.weight_unit: + self._abs_max_weight = to_grams(self.max_weight, self.weight_unit) + else: + self._abs_max_weight = None + + super().save(*args, **kwargs) + @property def units(self): """ @@ -449,8 +466,24 @@ class Rack(NetBoxModel): return int(allocated_draw / available_power_total * 100) + @cached_property + def total_weight(self): + total_weight = sum( + device.device_type._abs_weight + for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type') + ) + total_weight += sum( + module.module_type._abs_weight + for module in Module.objects.filter(device__rack=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self._abs_weight: + total_weight += self._abs_weight + return round(total_weight / 1000, 2) -class RackReservation(NetBoxModel): + +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ @@ -478,6 +511,9 @@ class RackReservation(NetBoxModel): ) clone_fields = ('rack', 'user', 'tenant') + prerequisite_models = ( + 'dcim.Rack', + ) class Meta: ordering = ['created', 'pk'] @@ -485,10 +521,6 @@ class RackReservation(NetBoxModel): def __str__(self): return "Reservation for rack {}".format(self.rack) - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), Rack, ] - def get_absolute_url(self): return reverse('dcim:rackreservation', args=[self.pk]) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index f5c8e6d9d..c035fc1db 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,12 +2,12 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey +from django.utils.translation import gettext as _ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -28,25 +28,6 @@ class Region(NestedGroupModel): states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are also considered to be members of its parent and ancestor region(s). """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -62,38 +43,26 @@ class Region(NestedGroupModel): constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_region_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_region_name', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_name', + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_region_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_region_slug', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_slug', + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - regions = Region.objects.exclude(pk=self.pk) - if regions.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this name already exists.' - }) - if regions.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -114,25 +83,6 @@ class SiteGroup(NestedGroupModel): within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be nested recursively to form a hierarchy. """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -148,38 +98,26 @@ class SiteGroup(NestedGroupModel): constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_sitegroup_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_sitegroup_name', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_name', + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_sitegroup_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_sitegroup_slug', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_slug', + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - site_groups = SiteGroup.objects.exclude(pk=self.pk) - if site_groups.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this name already exists.' - }) - if site_groups.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -194,7 +132,7 @@ class SiteGroup(NestedGroupModel): # Sites # -class Site(NetBoxModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -241,7 +179,7 @@ class Site(NetBoxModel): facility = models.CharField( max_length=50, blank=True, - help_text='Local facility ID or description' + help_text=_('Local facility ID or description') ) asns = models.ManyToManyField( to='ipam.ASN', @@ -251,10 +189,6 @@ class Site(NetBoxModel): time_zone = TimeZoneField( blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) physical_address = models.CharField( max_length=200, blank=True @@ -268,17 +202,14 @@ class Site(NetBoxModel): decimal_places=6, blank=True, null=True, - help_text='GPS coordinate (latitude)' + help_text=_('GPS coordinate (latitude)') ) longitude = models.DecimalField( max_digits=9, decimal_places=6, blank=True, null=True, - help_text='GPS coordinate (longitude)' - ) - comments = models.TextField( - blank=True + help_text=_('GPS coordinate (longitude)') ) # Generic relations @@ -322,25 +253,11 @@ class Location(NestedGroupModel): A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a site, or a room within a building, for example. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, related_name='locations' ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) status = models.CharField( max_length=50, choices=LocationStatusChoices, @@ -353,10 +270,6 @@ class Location(NestedGroupModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -373,48 +286,35 @@ class Location(NestedGroupModel): ) clone_fields = ('site', 'parent', 'status', 'tenant', 'description') + prerequisite_models = ( + 'dcim.Site', + ) class Meta: ordering = ['site', 'name'] constraints = ( models.UniqueConstraint( fields=('site', 'parent', 'name'), - name='dcim_location_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('site', 'name'), - name='dcim_location_name', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_name', + condition=Q(parent__isnull=True), + violation_error_message="A location with this name already exists within the specified site." ), models.UniqueConstraint( fields=('site', 'parent', 'slug'), - name='dcim_location_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('site', 'slug'), - name='dcim_location_slug', - condition=Q(parent=None) + name='%(app_label)s_%(class)s_slug', + condition=Q(parent__isnull=True), + violation_error_message="A location with this slug already exists within the specified site." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - locations = Location.objects.exclude(pk=self.pk) - if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this name in site {self.site} already exists." - }) - if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this slug in site {self.site} already exists." - }) - - super().validate_unique(exclude=exclude) - - @classmethod - def get_prerequisite_models(cls): - return [Site, ] - def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py new file mode 100644 index 000000000..bae4f030f --- /dev/null +++ b/netbox/dcim/search.py @@ -0,0 +1,317 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class CableIndex(SearchIndex): + model = models.Cable + fields = ( + ('label', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ConsolePortIndex(SearchIndex): + model = models.ConsolePort + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ('speed', 2000), + ) + + +@register_search +class ConsoleServerPortIndex(SearchIndex): + model = models.ConsoleServerPort + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ('speed', 2000), + ) + + +@register_search +class DeviceIndex(SearchIndex): + model = models.Device + fields = ( + ('asset_tag', 50), + ('serial', 60), + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class DeviceBayIndex(SearchIndex): + model = models.DeviceBay + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ) + + +@register_search +class DeviceRoleIndex(SearchIndex): + model = models.DeviceRole + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class DeviceTypeIndex(SearchIndex): + model = models.DeviceType + fields = ( + ('model', 100), + ('part_number', 200), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class FrontPortIndex(SearchIndex): + model = models.FrontPort + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ) + + +@register_search +class InterfaceIndex(SearchIndex): + model = models.Interface + fields = ( + ('name', 100), + ('label', 200), + ('mac_address', 300), + ('wwn', 300), + ('description', 500), + ('mtu', 2000), + ('speed', 2000), + ) + + +@register_search +class InventoryItemIndex(SearchIndex): + model = models.InventoryItem + fields = ( + ('asset_tag', 50), + ('serial', 60), + ('name', 100), + ('label', 200), + ('description', 500), + ('part_id', 2000), + ) + + +@register_search +class LocationIndex(SearchIndex): + model = models.Location + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class ManufacturerIndex(SearchIndex): + model = models.Manufacturer + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class ModuleIndex(SearchIndex): + model = models.Module + fields = ( + ('asset_tag', 50), + ('serial', 60), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ModuleBayIndex(SearchIndex): + model = models.ModuleBay + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ) + + +@register_search +class ModuleTypeIndex(SearchIndex): + model = models.ModuleType + fields = ( + ('model', 100), + ('part_number', 200), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class PlatformIndex(SearchIndex): + model = models.Platform + fields = ( + ('name', 100), + ('slug', 110), + ('napalm_driver', 300), + ('description', 500), + ) + + +@register_search +class PowerFeedIndex(SearchIndex): + model = models.PowerFeed + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class PowerOutletIndex(SearchIndex): + model = models.PowerOutlet + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ) + + +@register_search +class PowerPanelIndex(SearchIndex): + model = models.PowerPanel + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class PowerPortIndex(SearchIndex): + model = models.PowerPort + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ('maximum_draw', 2000), + ('allocated_draw', 2000), + ) + + +@register_search +class RackIndex(SearchIndex): + model = models.Rack + fields = ( + ('asset_tag', 50), + ('serial', 60), + ('name', 100), + ('facility_id', 200), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class RackReservationIndex(SearchIndex): + model = models.RackReservation + fields = ( + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class RackRoleIndex(SearchIndex): + model = models.RackRole + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class RearPortIndex(SearchIndex): + model = models.RearPort + fields = ( + ('name', 100), + ('label', 200), + ('description', 500), + ) + + +@register_search +class RegionIndex(SearchIndex): + model = models.Region + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class SiteIndex(SearchIndex): + model = models.Site + fields = ( + ('name', 100), + ('facility', 100), + ('slug', 110), + ('description', 500), + ('physical_address', 2000), + ('shipping_address', 2000), + ('comments', 5000), + ) + + +@register_search +class SiteGroupIndex(SearchIndex): + model = models.SiteGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class VirtualChassisIndex(SearchIndex): + model = models.VirtualChassis + fields = ( + ('name', 100), + ('domain', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class VirtualDeviceContextIndex(SearchIndex): + model = models.VirtualDeviceContext + fields = ( + ('name', 100), + ('identifier', 300), + ('description', 500), + ('comments', 5000), + ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index e5410e42a..6e9d49719 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -111,6 +111,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): order_by=('_abs_length', 'length_unit') ) color = columns.ColorColumn() + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:cable_list' ) @@ -120,7 +121,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'length', 'tags', 'created', 'last_updated', + 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b129c963..7b8ea1ed3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,21 +1,5 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePort, - ConsoleServerPort, - Device, - DeviceBay, - DeviceRole, - FrontPort, - Interface, - InventoryItem, - InventoryItemRole, - ModuleBay, - Platform, - PowerOutlet, - PowerPort, - RearPort, - VirtualChassis, -) +from dcim import models from django_tables2.utils import Accessor from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin @@ -52,6 +36,7 @@ __all__ = ( 'PowerPortTable', 'RearPortTable', 'VirtualChassisTable', + 'VirtualDeviceContextTable' ) @@ -106,7 +91,7 @@ class DeviceRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = DeviceRole + model = models.DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', @@ -137,7 +122,7 @@ class PlatformTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Platform + model = models.Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', @@ -154,7 +139,8 @@ class PlatformTable(NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), - template_code=DEVICE_LINK + template_code=DEVICE_LINK, + linkify=True ) status = columns.ChoiceFieldColumn() region = tables.Column( @@ -220,12 +206,12 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -235,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): name = tables.TemplateColumn( - template_code=DEVICE_LINK + template_code=DEVICE_LINK, + linkify=True ) status = columns.ChoiceFieldColumn() site = tables.Column( @@ -252,7 +239,7 @@ class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -326,7 +313,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -345,7 +332,7 @@ class DeviceConsolePortTable(ConsolePortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' @@ -368,7 +355,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -388,7 +375,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -411,7 +398,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -432,7 +419,7 @@ class DevicePowerPortTable(PowerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -460,7 +447,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -480,7 +467,7 @@ class DevicePowerOutletTable(PowerOutletTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -536,6 +523,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi orderable=False, verbose_name='Wireless LANs' ) + vdcs = columns.ManyToManyColumn( + linkify_item=True, + verbose_name='VDCs' + ) vrf = tables.Column( linkify=True ) @@ -544,12 +535,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', - 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -578,12 +569,12 @@ class DeviceInterfaceTable(InterfaceTable): ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) @@ -617,7 +608,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', @@ -640,7 +631,7 @@ class DeviceFrontPortTable(FrontPortTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -666,7 +657,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', @@ -686,7 +677,7 @@ class DeviceRearPortTable(RearPortTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -727,7 +718,7 @@ class DeviceBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags', 'created', 'last_updated', @@ -748,7 +739,7 @@ class DeviceDeviceBayTable(DeviceBayTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) @@ -777,7 +768,7 @@ class ModuleBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', @@ -791,7 +782,7 @@ class DeviceModuleBayTable(ModuleBayTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', 'actions', @@ -821,7 +812,7 @@ class InventoryItemTable(DeviceComponentTable): cable = None # Override DeviceComponentTable class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', @@ -840,7 +831,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'description', 'discovered', 'tags', 'actions', @@ -865,7 +856,7 @@ class InventoryItemRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItemRole + model = models.InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) @@ -888,11 +879,61 @@ class VirtualChassisTable(NetBoxTable): url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) class Meta(NetBoxTable.Meta): - model = VirtualChassis - fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) + model = models.VirtualChassis + fields = ( + 'pk', 'id', 'name', 'domain', 'master', 'member_count', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') + + +class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + linkify=True + ) + device = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK, + linkify=True + ) + status = columns.ChoiceFieldColumn() + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + interface_count = columns.LinkedCountColumn( + viewname='dcim:interface_list', + url_params={'vdc_id': 'pk'}, + verbose_name='Interfaces' + ) + + comments = columns.MarkdownColumn() + + tags = columns.TagColumn( + url_name='dcim:vdc_list' + ) + + class Meta(NetBoxTable.Meta): + model = models.VirtualDeviceContext + fields = ( + 'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip', + ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 728fa3dd0..42d9c7879 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,23 +1,9 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePortTemplate, - ConsoleServerPortTemplate, - DeviceBayTemplate, - DeviceType, - FrontPortTemplate, - InterfaceTemplate, - InventoryItemTemplate, - Manufacturer, - ModuleBayTemplate, - PowerOutletTemplate, - PowerPortTemplate, - RearPortTemplate, -) -from tenancy.tables import ContactsColumnMixin +from dcim import models from netbox.tables import NetBoxTable, columns - -from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS +from tenancy.tables import ContactsColumnMixin +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT __all__ = ( 'ConsolePortTemplateTable', @@ -60,7 +46,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Manufacturer + model = models.Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated', @@ -97,12 +83,16 @@ class DeviceTypeTable(NetBoxTable): u_height = columns.TemplateColumn( template_code='{{ value|floatformat }}' ) + weight = columns.TemplateColumn( + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): - model = DeviceType + model = models.DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', @@ -132,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsolePortTemplate + model = models.ConsolePortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -144,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsoleServerPortTemplate + model = models.ConsoleServerPortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -156,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerPortTemplate + model = models.PowerPortTemplate fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') empty_text = "None" @@ -168,7 +158,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerOutletTemplate + model = models.PowerOutletTemplate fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') empty_text = "None" @@ -183,7 +173,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InterfaceTemplate + model = models.InterfaceTemplate fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') empty_text = "None" @@ -199,7 +189,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = FrontPortTemplate + model = models.FrontPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions') empty_text = "None" @@ -212,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = RearPortTemplate + model = models.RearPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions') empty_text = "None" @@ -223,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ModuleBayTemplate + model = models.ModuleBayTemplate fields = ('pk', 'name', 'label', 'position', 'description', 'actions') empty_text = "None" @@ -234,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = DeviceBayTemplate + model = models.DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" @@ -254,7 +244,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InventoryItemTemplate + model = models.InventoryItemTemplate fields = ( 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index e40d7bd80..8e4ca309e 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -2,6 +2,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType from netbox.tables import NetBoxTable, columns +from .template_code import WEIGHT __all__ = ( 'ModuleTable', @@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + weight = columns.TemplateColumn( + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'description', 'comments', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', @@ -51,6 +56,7 @@ class ModuleTable(NetBoxTable): module_type = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:module_list' @@ -59,9 +65,9 @@ class ModuleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Module fields = ( - 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments', - 'tags', + 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', + 'description', 'comments', 'tags', ) default_columns = ( - 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', + 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 04012ea4a..feff29e12 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -31,6 +31,7 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -38,7 +39,8 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = PowerPanel fields = ( - 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -77,7 +79,7 @@ class PowerFeedTable(CableTerminationTable): fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', 'created', 'last_updated', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index ab6ee6eb3..cb9aae6fd 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,9 +1,10 @@ import django_tables2 as tables -from dcim.models import Rack, RackReservation, RackRole from django_tables2.utils import Accessor -from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin +from dcim.models import Rack, RackReservation, RackRole from netbox.tables import NetBoxTable, columns +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin +from .template_code import WEIGHT __all__ = ( 'RackTable', @@ -79,13 +80,22 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): template_code="{{ record.outer_depth }} {{ record.outer_unit }}", verbose_name='Outer Depth' ) + weight = columns.TemplateColumn( + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) + max_weight = columns.TemplateColumn( + template_code=WEIGHT, + order_by=('_abs_max_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', - 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', - 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', + 'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight', + 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -117,6 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Units' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) @@ -124,7 +135,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = RackReservation fields = ( - 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags', - 'actions', 'created', 'last_updated', + 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index f013025f7..a4de18cee 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Site fields = ( - 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', - 'contacts', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', + 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', + 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index dfc77b854..dd0581ddc 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -15,10 +15,13 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ +WEIGHT = """ +{% load helpers %} +{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %} +""" + DEVICE_LINK = """ - - {{ record.name|default:'Unnamed device' }} - +{{ value|default:'Unnamed device' }} """ DEVICEBAY_STATUS = """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2697c29b2..301af8d18 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): 'device': device.pk, 'module_bay': module_bays[3].pk, 'module_type': module_types[0].pk, + 'status': ModuleStatusChoices.STATUS_ACTIVE, 'serial': 'ABC123', 'asset_tag': 'Foo1', }, @@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): 'device': device.pk, 'module_bay': module_bays[4].pk, 'module_type': module_types[1].pk, + 'status': ModuleStatusChoices.STATUS_ACTIVE, 'serial': 'DEF456', 'asset_tag': 'Foo2', }, @@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): 'device': device.pk, 'module_bay': module_bays[5].pk, 'module_type': module_types[2].pk, + 'status': ModuleStatusChoices.STATUS_ACTIVE, 'serial': 'GHI789', 'asset_tag': 'Foo3', }, @@ -1485,6 +1488,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) Interface.objects.bulk_create(interfaces) + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=device), + VirtualDeviceContext(name='VDC 2', identifier=2, device=device) + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + vlans = ( VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), @@ -1533,6 +1542,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[0].pk], 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -1543,6 +1553,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 7', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -1551,6 +1562,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 8', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -1945,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase): class ConnectedDeviceTest(APITestCase): - def setUp(self): - - super().setUp() - + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000') - self.device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + devices = ( + Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site), + Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site), ) - self.device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site + Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='eth0'), + Interface(device=devices[1], name='eth0'), + Interface(device=devices[0], name='eth1'), # Not connected ) - self.interface1 = Interface.objects.create(device=self.device1, name='eth0') - self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected + Interface.objects.bulk_create(interfaces) - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) + cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]]) cable.save() @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_get_connected_device(self): url = reverse('dcim-api:connected-device-list') - url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}' + url_params = f'?peer_device=TestDevice1&peer_interface=eth0' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['name'], self.device2.name) + self.assertEqual(response.data['name'], 'TestDevice2') - url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}' + url_params = f'?peer_device=TestDevice1&peer_interface=eth1' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) @@ -2163,3 +2175,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): 'type': REDUNDANT, }, ] + + +class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): + model = VirtualDeviceContext + brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type') + devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000') + + devices = ( + Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + cls.create_data = [ + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 1', + 'identifier': 1, + }, + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 2', + 'identifier': 2, + }, + { + 'device': devices[1].pk, + 'status': 'active', + 'name': 'VDC 3', + 'identifier': 3, + }, + ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 05bb64796..874e3474c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) racks = ( - Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), + Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) Rack.objects.bulk_create(racks) @@ -517,6 +517,18 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_weight(self): + params = {'max_weight': [1000, 2000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() @@ -688,9 +700,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), - DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) DeviceType.objects.bulk_create(device_types) @@ -839,6 +851,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'inventory_items': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() @@ -855,9 +875,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) module_types = ( - ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), - ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), - ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), + ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) ModuleType.objects.bulk_create(module_types) @@ -943,6 +963,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() @@ -1611,6 +1639,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): def test_name(self): params = {'name': ['Device 1', 'Device 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Test case insensitivity + params = {'name': ['DEVICE 1', 'DEVICE 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asset_tag(self): params = {'asset_tag': ['1001', '1002']} @@ -1849,15 +1880,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): ModuleBay.objects.bulk_create(module_bays) modules = ( - Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'), - Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'), - Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'), - Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'), - Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'), - Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'), - Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'), - Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'), - Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'), + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'), + Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'), + Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'), + Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'), + Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'), + Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'), + Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'), ) Module.objects.bulk_create(modules) @@ -1885,6 +1916,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_id': [device_types[0].pk, device_types[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_status(self): + params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_serial(self): params = {'serial': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2654,6 +2689,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VRF.objects.bulk_create(vrfs) + # Virtual Device Context Creation + vdcs = ( + VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + # VirtualChassis assignment for filtering virtual_chassis = VirtualChassis.objects.create(master=devices[0]) Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) @@ -2766,6 +2808,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Interface.objects.bulk_create(interfaces) + interfaces[3].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[4].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[5].vdcs.set([vdcs[0]]) + interfaces[6].vdcs.set([vdcs[0]]) + interfaces[7].vdcs.set([vdcs[1]]) + # Cables Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save() @@ -2970,6 +3018,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vdc(self): + params = {'vdc': ['VDC 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_id': vdc.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_vdc_identifier(self): + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() @@ -4227,4 +4290,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -# TODO: Connection filters +class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VirtualDeviceContext.objects.all() + filterset = VirtualDeviceContextFilterSet + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE), + VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type='virtual'), + Interface(device=devices[0], name='Interface 2', type='virtual'), + ) + Interface.objects.bulk_create(interfaces) + + interfaces[0].vdcs.set([vdcs[0]]) + interfaces[1].vdcs.set([vdcs[1]]) + + addresses = ( + IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), + IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + ) + IPAddress.objects.bulk_create(addresses) + + vdcs[0].primary_ip4 = addresses[0] + vdcs[0].save() + vdcs[1].primary_ip4 = addresses[1] + vdcs[1].save() + + def test_device(self): + params = {'device': ['Device 1', 'Device 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_status(self): + params = {'status': ['active']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device_id(self): + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_has_primary_ip(self): + params = {'has_primary_ip': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'has_primary_ip': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 0e02b0de5..e9a577648 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -73,7 +73,8 @@ class LocationTestCase(TestCase): class RackTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): sites = ( Site(name='Site 1', slug='site-1'), @@ -240,30 +241,31 @@ class RackTestCase(TestCase): class DeviceTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): - self.site = Site.objects.create(name='Test Site 1', slug='test-site-1') + site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - self.device_type = DeviceType.objects.create( + device_type = DeviceType.objects.create( manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) - self.device_role = DeviceRole.objects.create( + device_role = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) # Create DeviceType components ConsolePortTemplate( - device_type=self.device_type, + device_type=device_type, name='Console Port 1' ).save() ConsoleServerPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Console Server Port 1' ).save() ppt = PowerPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Power Port 1', maximum_draw=1000, allocated_draw=500 @@ -271,21 +273,21 @@ class DeviceTestCase(TestCase): ppt.save() PowerOutletTemplate( - device_type=self.device_type, + device_type=device_type, name='Power Outlet 1', power_port=ppt, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A ).save() InterfaceTemplate( - device_type=self.device_type, + device_type=device_type, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True ).save() rpt = RearPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=8 @@ -293,7 +295,7 @@ class DeviceTestCase(TestCase): rpt.save() FrontPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rpt, @@ -301,12 +303,12 @@ class DeviceTestCase(TestCase): ).save() ModuleBayTemplate( - device_type=self.device_type, + device_type=device_type, name='Module Bay 1' ).save() DeviceBayTemplate( - device_type=self.device_type, + device_type=device_type, name='Device Bay 1' ).save() @@ -315,9 +317,9 @@ class DeviceTestCase(TestCase): Ensure that all Device components are copied automatically from the DeviceType. """ d = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name='Test Device 1' ) d.save() @@ -381,10 +383,10 @@ class DeviceTestCase(TestCase): def test_multiple_unnamed_devices(self): device1 = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, - name='' + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name=None ) device1.save() @@ -392,19 +394,40 @@ class DeviceTestCase(TestCase): site=device1.site, device_type=device1.device_type, device_role=device1.device_role, - name='' + name=None ) device2.full_clean() device2.save() - self.assertEqual(Device.objects.filter(name='').count(), 2) + self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) + + def test_device_name_case_sensitivity(self): + + device1 = Device( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='device 1' + ) + device1.save() + + device2 = Device( + site=device1.site, + device_type=device1.device_type, + device_role=device1.device_role, + name='DEVICE 1' + ) + + # Uniqueness validation for name should ignore case + with self.assertRaises(ValidationError): + device2.full_clean() def test_device_duplicate_names(self): device1 = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name='Test Device 1' ) device1.save() @@ -438,7 +461,8 @@ class DeviceTestCase(TestCase): class CableTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -448,72 +472,76 @@ class CableTestCase(TestCase): devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.device1 = Device.objects.create( + device1 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site ) - self.device2 = Device.objects.create( + device2 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site ) - self.interface1 = Interface.objects.create(device=self.device1, name='eth0') - self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - self.interface3 = Interface.objects.create(device=self.device2, name='eth1') - self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) - self.cable.save() + interface1 = Interface.objects.create(device=device1, name='eth0') + interface2 = Interface.objects.create(device=device2, name='eth0') + interface3 = Interface.objects.create(device=device2, name='eth1') + Cable(a_terminations=[interface1], b_terminations=[interface2]).save() - self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') - self.patch_pannel = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site + power_port1 = PowerPort.objects.create(device=device2, name='psu1') + patch_pannel = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site ) - self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c') - self.front_port1 = FrontPort.objects.create( - device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1 + rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c') + front_port1 = FrontPort.objects.create( + device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1 ) - self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2) - self.front_port2 = FrontPort.objects.create( - device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1 + rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2) + front_port2 = FrontPort.objects.create( + device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1 ) - self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3) - self.front_port3 = FrontPort.objects.create( - device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1 + rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3) + front_port3 = FrontPort.objects.create( + device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1 ) - self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3) - self.front_port4 = FrontPort.objects.create( - device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 + rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3) + front_port4 = FrontPort.objects.create( + device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1 ) - self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') - provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider) - self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') - self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A') + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) + circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') + circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') + circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ When a new Cable is created, it must be cached on either termination point. """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.assertEqual(self.interface1.cable, self.cable) - self.assertEqual(self.interface2.cable, self.cable) - self.assertEqual(self.interface1.cable_end, 'A') - self.assertEqual(self.interface2.cable_end, 'B') - self.assertEqual(self.interface1.link_peers, [self.interface2]) - self.assertEqual(self.interface2.link_peers, [self.interface1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + cable = Cable.objects.first() + self.assertEqual(interface1.cable, cable) + self.assertEqual(interface2.cable, cable) + self.assertEqual(interface1.cable_end, 'A') + self.assertEqual(interface2.cable_end, 'B') + self.assertEqual(interface1.link_peers, [interface2]) + self.assertEqual(interface2.link_peers, [interface1]) def test_cable_deletion(self): """ When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method should still return the PK of the string even after being nullified. """ - self.cable.delete() - self.assertIsNone(self.cable.pk) - self.assertNotEqual(str(self.cable), '#None') - interface1 = Interface.objects.get(pk=self.interface1.pk) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + cable = Cable.objects.first() + + cable.delete() + self.assertIsNone(cable.pk) + self.assertNotEqual(str(cable), '#None') + interface1 = Interface.objects.get(pk=interface1.pk) self.assertIsNone(interface1.cable) self.assertListEqual(interface1.link_peers, []) - interface2 = Interface.objects.get(pk=self.interface2.pk) + interface2 = Interface.objects.get(pk=interface2.pk) self.assertIsNone(interface2.cable) self.assertListEqual(interface2.link_peers, []) @@ -521,7 +549,10 @@ class CableTestCase(TestCase): """ The clean method should ensure that all terminations at either end of a Cable belong to the same parent object. """ - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1') + + cable = Cable(a_terminations=[interface1], b_terminations=[powerport1]) with self.assertRaises(ValidationError): cable.clean() @@ -529,7 +560,11 @@ class CableTestCase(TestCase): """ The clean method should ensure that all terminations at either end of a Cable are of the same type. """ - cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1') + rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1') + + cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1]) with self.assertRaises(ValidationError): cable.clean() @@ -537,8 +572,11 @@ class CableTestCase(TestCase): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1') + # An interface cannot be connected to a power port, for example - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + cable = Cable(a_terminations=[interface1], b_terminations=[powerport1]) with self.assertRaises(ValidationError): cable.clean() @@ -546,7 +584,10 @@ class CableTestCase(TestCase): """ Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork """ - cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3]) + interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1') + circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A') + + cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3]) with self.assertRaises(ValidationError): cable.clean() @@ -554,8 +595,11 @@ class CableTestCase(TestCase): """ A cable cannot terminate to a virtual interface """ - virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) - cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface]) + device1 = Device.objects.get(name='TestDevice1') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + + virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) + cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface]) with self.assertRaises(ValidationError): cable.clean() @@ -563,7 +607,63 @@ class CableTestCase(TestCase): """ A cable cannot terminate to a wireless interface """ - wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) - cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) + device1 = Device.objects.get(name='TestDevice1') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + + wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) + cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() + + +class VirtualDeviceContextTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + ) + + def test_vdc_and_interface_creation(self): + device = Device.objects.first() + + vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') + vdc.full_clean() + vdc.save() + + interface = Interface(device=device, name='Eth1/1', type='10gbase-t') + interface.full_clean() + interface.save() + + interface.vdcs.set([vdc]) + + def test_vdc_duplicate_name(self): + device = Device.objects.first() + + vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() + + def test_vdc_duplicate_identifier(self): + device = Device.objects.first() + + vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py index 5c42b3ab4..8edaf0a90 100644 --- a/netbox/dcim/tests/test_natural_ordering.py +++ b/netbox/dcim/tests/test_natural_ordering.py @@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, class NaturalOrderingTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase): devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.device = Device.objects.create( + Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) def test_interface_ordering_numeric(self): - + device = Device.objects.first() INTERFACES = [ '0', '0.0', @@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_linux(self): - + device = Device.objects.first() INTERFACES = [ 'eth0', 'eth0.1', @@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_junos(self): - + device = Device.objects.first() INTERFACES = [ 'xe-0/0/0', 'xe-0/0/1', @@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_ios(self): - + device = Device.objects.first() INTERFACES = [ 'GigabitEthernet0/1', 'GigabitEthernet0/2', @@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 16540af87..4b39dc27e 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,6 +1,10 @@ from decimal import Decimal +try: + from zoneinfo import ZoneInfo +except ImportError: + # Python 3.8 + from backports.zoneinfo import ZoneInfo -import pytz import yaml from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -12,8 +16,8 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF -from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant +from utilities.choices import ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -50,6 +54,13 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Region 6,region-6,Sixth region", ) + cls.csv_update_data = ( + "id,name,description", + f"{regions[0].pk},Region 7,Fourth region7", + f"{regions[1].pk},Region 8,Fifth region8", + f"{regions[2].pk},Region 0,Sixth region9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -87,6 +98,13 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Site Group 6,site-group-6,Sixth site group", ) + cls.csv_update_data = ( + "id,name,description", + f"{sitegroups[0].pk},Site Group 7,Fourth site group7", + f"{sitegroups[1].pk},Site Group 8,Fifth site group8", + f"{sitegroups[2].pk},Site Group 0,Sixth site group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -139,7 +157,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'facility': 'Facility X', 'asns': [asns[6].pk, asns[7].pk], - 'time_zone': pytz.UTC, + 'time_zone': ZoneInfo('UTC'), 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', 'shipping_address': '742 Evergreen Terrace, Springfield, USA', @@ -156,12 +174,19 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 6,site-6,staging", ) + cls.csv_update_data = ( + "id,name,status", + f"{sites[0].pk},Site 7,staging", + f"{sites[1].pk},Site 8,planned", + f"{sites[2].pk},Site 9,active", + ) + cls.bulk_edit_data = { 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'time_zone': pytz.timezone('US/Eastern'), + 'time_zone': ZoneInfo('US/Eastern'), 'description': 'New description', } @@ -202,6 +227,13 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) + cls.csv_update_data = ( + "id,name,description", + f"{locations[0].pk},Location 7,Fourth location7", + f"{locations[1].pk},Location 8,Fifth location8", + f"{locations[2].pk},Location 0,Sixth location9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -213,11 +245,12 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - RackRole.objects.bulk_create([ + rack_roles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), RackRole(name='Rack Role 2', slug='rack-role-2'), RackRole(name='Rack Role 3', slug='rack-role-3'), - ]) + ) + RackRole.objects.bulk_create(rack_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -236,6 +269,13 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Rack Role 6,rack-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{rack_roles[0].pk},Rack Role 7,New description7", + f"{rack_roles[1].pk},Rack Role 8,New description8", + f"{rack_roles[2].pk},Rack Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -259,11 +299,12 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): rack = Rack(name='Rack 1', site=site, location=location) rack.save() - RackReservation.objects.bulk_create([ + rack_reservations = ( RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'), RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'), RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), - ]) + ) + RackReservation.objects.bulk_create(rack_reservations) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -283,6 +324,13 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', ) + cls.csv_update_data = ( + 'id,description', + f'{rack_reservations[0].pk},New description1', + f'{rack_reservations[1].pk},New description2', + f'{rack_reservations[2].pk},New description3', + ) + cls.bulk_edit_data = { 'user': user3.pk, 'tenant': None, @@ -315,11 +363,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RackRole.objects.bulk_create(rackroles) - Rack.objects.bulk_create(( + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[0]), Rack(name='Rack 3', site=sites[0]), - )) + ) + Rack.objects.bulk_create(racks) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -340,15 +389,25 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_width': 500, 'outer_depth': 500, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'weight': 100, + 'max_weight': 2000, + 'weight_unit': WeightUnitChoices.UNIT_POUND, 'comments': 'Some comments', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,location,name,status,width,u_height", - "Site 1,,Rack 4,active,19,42", - "Site 1,Location 1,Rack 5,active,19,42", - "Site 2,Location 2,Rack 6,active,19,42", + "site,location,name,status,width,u_height,weight,max_weight,weight_unit", + "Site 1,,Rack 4,active,19,42,100,2000,kg", + "Site 1,Location 1,Rack 5,active,19,42,100,2000,kg", + "Site 2,Location 2,Rack 6,active,19,42,100,2000,kg", + ) + + cls.csv_update_data = ( + "id,name,status", + f"{racks[0].pk},Rack 7,{RackStatusChoices.STATUS_DEPRECATED}", + f"{racks[1].pk},Rack 8,{RackStatusChoices.STATUS_DEPRECATED}", + f"{racks[2].pk},Rack 9,{RackStatusChoices.STATUS_DEPRECATED}", ) cls.bulk_edit_data = { @@ -365,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_width': 30, 'outer_depth': 30, 'outer_unit': RackDimensionUnitChoices.UNIT_INCH, + 'weight': 200, + 'max_weight': 4000, + 'weight_unit': WeightUnitChoices.UNIT_POUND, 'comments': 'New comments', } @@ -383,11 +445,12 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Manufacturer.objects.bulk_create([ + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), - ]) + ) + Manufacturer.objects.bulk_create(manufacturers) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -405,6 +468,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) + cls.csv_update_data = ( + "id,name,description", + f"{manufacturers[0].pk},Manufacturer 7,Fourth manufacturer7", + f"{manufacturers[1].pk},Manufacturer 8,Fifth manufacturer8", + f"{manufacturers[2].pk},Manufacturer 9,Sixth manufacturer9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -1444,11 +1514,12 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - DeviceRole.objects.bulk_create([ + device_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), - ]) + ) + DeviceRole.objects.bulk_create(device_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1468,6 +1539,13 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Device Role 6,device-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{device_roles[0].pk},Device Role 7,New description7", + f"{device_roles[1].pk},Device Role 8,New description8", + f"{device_roles[2].pk},Device Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -1482,11 +1560,12 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - Platform.objects.bulk_create([ + platforms = ( Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer), Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), - ]) + ) + Platform.objects.bulk_create(platforms) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1507,6 +1586,13 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Platform 6,platform-6,Sixth platform", ) + cls.csv_update_data = ( + "id,name,description", + f"{platforms[0].pk},Platform 7,Fourth platform7", + f"{platforms[1].pk},Platform 8,Fifth platform8", + f"{platforms[2].pk},Platform 9,Sixth platform9", + ) + cls.bulk_edit_data = { 'napalm_driver': 'ios', 'description': 'New description', @@ -1554,11 +1640,12 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) - Device.objects.bulk_create([ + devices = ( Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), - ]) + ) + Device.objects.bulk_create(devices) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1595,6 +1682,13 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30", ) + cls.csv_update_data = ( + "id,status", + f"{devices[0].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + f"{devices[1].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + f"{devices[2].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + ) + cls.bulk_edit_data = { 'device_type': devicetypes[1].pk, 'device_role': deviceroles[1].pk, @@ -1800,19 +1894,28 @@ class ModuleTestCase( 'device': devices[0].pk, 'module_bay': module_bays[3].pk, 'module_type': module_types[0].pk, + 'status': ModuleStatusChoices.STATUS_ACTIVE, 'serial': 'A', 'tags': [t.pk for t in tags], } cls.bulk_edit_data = { 'module_type': module_types[3].pk, + 'status': ModuleStatusChoices.STATUS_PLANNED, } cls.csv_data = ( - "device,module_bay,module_type,serial,asset_tag", - "Device 2,Module Bay 1,Module Type 1,A,A", - "Device 2,Module Bay 2,Module Type 2,B,B", - "Device 2,Module Bay 3,Module Type 3,C,C", + "device,module_bay,module_type,status,serial,asset_tag", + "Device 2,Module Bay 1,Module Type 1,active,A,A", + "Device 2,Module Bay 2,Module Type 2,planned,B,B", + "Device 2,Module Bay 3,Module Type 3,failed,C,C", + ) + + cls.csv_update_data = ( + "id,status,serial", + f"{modules[0].pk},offline,Serial 2", + f"{modules[1].pk},offline,Serial 3", + f"{modules[2].pk},offline,Serial 1", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @@ -1855,44 +1958,45 @@ class ModuleTestCase( # Add 5 InterfaceTemplates to a ModuleType module_type = ModuleType.objects.first() interface_templates = [ - InterfaceTemplate(module_type=module_type, name=f'Interface {i}') for i in range(1, 6) + InterfaceTemplate(module_type=module_type, name=f'Interface {i}') + for i in range(1, 6) ] InterfaceTemplate.objects.bulk_create(interface_templates) - form_data = self.form_data.copy() - device = Device.objects.get(pk=form_data['device']) - # Create a module *without* replicating components - module_bay = ModuleBay.objects.get(pk=form_data['module_bay']) + device = Device.objects.get(name='Device 2') + module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4') csv_data = [ - "device,module_bay,module_type,replicate_components", - f"{device.name},{module_bay.name},{module_type.model},false" + "device,module_bay,module_type,status,replicate_components", + f"{device.name},{module_bay.name},{module_type.model},active,false" ] request = { 'path': self._get_url('import'), 'data': { - 'csv': '\n'.join(csv_data), + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, } } - initial_count = self._get_queryset().count() + initial_count = Module.objects.count() self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1) + self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1) self.assertEqual(Interface.objects.filter(device=device).count(), 0) # Create a second module (in the next bay) with replicated components - module_bay = ModuleBay.objects.get(pk=(form_data['module_bay'] + 1)) - csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},true" + module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5') + csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true" request = { 'path': self._get_url('import'), 'data': { - 'csv': '\n'.join(csv_data), + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, } } - initial_count = self._get_queryset().count() + initial_count = Module.objects.count() self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1) + self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1) self.assertEqual(Interface.objects.filter(device=device).count(), 5) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @@ -1955,13 +2059,14 @@ class ModuleTestCase( # Create a module with adopted components module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4') csv_data = [ - "device,module_bay,module_type,replicate_components,adopt_components", - f"{device.name},{module_bay.name},{module_type.model},false,true" + "device,module_bay,module_type,status,replicate_components,adopt_components", + f"{device.name},{module_bay.name},{module_type.model},active,false,true" ] request = { 'path': self._get_url('import'), 'data': { - 'csv': '\n'.join(csv_data), + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, } } @@ -1984,11 +2089,12 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ConsolePort.objects.bulk_create([ + console_ports = ( ConsolePort(device=device, name='Console Port 1'), ConsolePort(device=device, name='Console Port 2'), ConsolePort(device=device, name='Console Port 3'), - ]) + ) + ConsolePort.objects.bulk_create(console_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2022,6 +2128,13 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{console_ports[0].pk},Console Port 7,New description7", + f"{console_ports[1].pk},Console Port 8,New description8", + f"{console_ports[2].pk},Console Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleport = ConsolePort.objects.first() @@ -2043,11 +2156,12 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ConsoleServerPort.objects.bulk_create([ + console_server_ports = ( ConsoleServerPort(device=device, name='Console Server Port 1'), ConsoleServerPort(device=device, name='Console Server Port 2'), ConsoleServerPort(device=device, name='Console Server Port 3'), - ]) + ) + ConsoleServerPort.objects.bulk_create(console_server_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2079,6 +2193,13 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{console_server_ports[0].pk},Console Server Port 7,New description 7", + f"{console_server_ports[1].pk},Console Server Port 8,New description 8", + f"{console_server_ports[2].pk},Console Server Port 9,New description 9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleserverport = ConsoleServerPort.objects.first() @@ -2100,11 +2221,12 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - PowerPort.objects.bulk_create([ + power_ports = ( PowerPort(device=device, name='Power Port 1'), PowerPort(device=device, name='Power Port 2'), PowerPort(device=device, name='Power Port 3'), - ]) + ) + PowerPort.objects.bulk_create(power_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2142,6 +2264,13 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{power_ports[0].pk},Power Port 7,New description7", + f"{power_ports[1].pk},Power Port 8,New description8", + f"{power_ports[2].pk},Power Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): powerport = PowerPort.objects.first() @@ -2169,11 +2298,12 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): ) PowerPort.objects.bulk_create(powerports) - PowerOutlet.objects.bulk_create([ + power_outlets = ( PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), - ]) + ) + PowerOutlet.objects.bulk_create(power_outlets) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2211,6 +2341,13 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{power_outlets[0].pk},Power Outlet 7,New description7", + f"{power_outlets[1].pk},Power Outlet 8,New description8", + f"{power_outlets[2].pk},Power Outlet 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): poweroutlet = PowerOutlet.objects.first() @@ -2337,6 +2474,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) + cls.csv_update_data = ( + "id,name,description", + f"{interfaces[0].pk},Interface 7,New description7", + f"{interfaces[1].pk},Interface 8,New description8", + f"{interfaces[2].pk},Interface 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): interface1, interface2 = Interface.objects.all()[:2] @@ -2364,11 +2508,12 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ) RearPort.objects.bulk_create(rearports) - FrontPort.objects.bulk_create([ + front_ports = ( FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]), FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]), FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), - ]) + ) + FrontPort.objects.bulk_create(front_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2403,6 +2548,13 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + cls.csv_update_data = ( + "id,name,description", + f"{front_ports[0].pk},Front Port 7,New description7", + f"{front_ports[1].pk},Front Port 8,New description8", + f"{front_ports[2].pk},Front Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): frontport = FrontPort.objects.first() @@ -2424,11 +2576,12 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - RearPort.objects.bulk_create([ + rear_ports = ( RearPort(device=device, name='Rear Port 1'), RearPort(device=device, name='Rear Port 2'), RearPort(device=device, name='Rear Port 3'), - ]) + ) + RearPort.objects.bulk_create(rear_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2462,6 +2615,13 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + cls.csv_update_data = ( + "id,name,description", + f"{rear_ports[0].pk},Rear Port 7,New description7", + f"{rear_ports[1].pk},Rear Port 8,New description8", + f"{rear_ports[2].pk},Rear Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): rearport = RearPort.objects.first() @@ -2483,11 +2643,12 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ModuleBay.objects.bulk_create([ + module_bays = ( ModuleBay(device=device, name='Module Bay 1'), ModuleBay(device=device, name='Module Bay 2'), ModuleBay(device=device, name='Module Bay 3'), - ]) + ) + ModuleBay.objects.bulk_create(module_bays) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2516,6 +2677,13 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Module Bay 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{module_bays[0].pk},Module Bay 7,New description7", + f"{module_bays[1].pk},Module Bay 8,New description8", + f"{module_bays[2].pk},Module Bay 9,New description9", + ) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay @@ -2528,11 +2696,12 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): # Update the DeviceType subdevice role to allow adding DeviceBays DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) - DeviceBay.objects.bulk_create([ + device_bays = ( DeviceBay(device=device, name='Device Bay 1'), DeviceBay(device=device, name='Device Bay 2'), DeviceBay(device=device, name='Device Bay 3'), - ]) + ) + DeviceBay.objects.bulk_create(device_bays) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2561,6 +2730,13 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Device Bay 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{device_bays[0].pk},Device Bay 7,New description7", + f"{device_bays[1].pk},Device Bay 8,New description8", + f"{device_bays[2].pk},Device Bay 9,New description9", + ) + class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem @@ -2577,9 +2753,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): ) InventoryItemRole.objects.bulk_create(roles) - InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) + inventory_item1 = InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) + inventory_item2 = InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) + inventory_item3 = InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2623,6 +2799,13 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Inventory Item 6,Inventory Item 3", ) + cls.csv_update_data = ( + "id,name,description", + f"{inventory_item1.pk},Inventory Item 7,New description7", + f"{inventory_item2.pk},Inventory Item 8,New description8", + f"{inventory_item3.pk},Inventory Item 9,New description9", + ) + class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = InventoryItemRole @@ -2630,11 +2813,12 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - InventoryItemRole.objects.bulk_create([ + inventory_item_roles = ( InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), - ]) + ) + InventoryItemRole.objects.bulk_create(inventory_item_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2653,6 +2837,13 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Inventory Item Role 6,inventory-item-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{inventory_item_roles[0].pk},Inventory Item Role 7,New description7", + f"{inventory_item_roles[1].pk},Inventory Item Role 8,New description8", + f"{inventory_item_roles[2].pk},Inventory Item Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -2705,9 +2896,12 @@ class CableTestCase( ) Interface.objects.bulk_create(interfaces) - Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save() - Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save() - Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save() + cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6) + cable1.save() + cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6) + cable2.save() + cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6) + cable3.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2733,6 +2927,13 @@ class CableTestCase( "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", ) + cls.csv_update_data = ( + "id,label,color", + f"{cable1.pk},New label7,00ff00", + f"{cable2.pk},New label8,00ff00", + f"{cable3.pk},New label9,00ff00", + ) + cls.bulk_edit_data = { 'type': CableTypeChoices.TYPE_CAT5E, 'status': LinkStatusChoices.STATUS_CONNECTED, @@ -2816,6 +3017,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VC6,Domain 6,Device 12", ) + cls.csv_update_data = ( + "id,name,domain", + f"{vc1.pk},VC7,Domain 7", + f"{vc2.pk},VC8,Domain 8", + f"{vc3.pk},VC9,Domain 9", + ) + cls.bulk_edit_data = { 'domain': 'domain-x', } @@ -2840,11 +3048,12 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): for location in locations: location.save() - PowerPanel.objects.bulk_create(( + power_panels = ( PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), - )) + ) + PowerPanel.objects.bulk_create(power_panels) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2862,6 +3071,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 1,Location 1,Power Panel 6", ) + cls.csv_update_data = ( + "id,name", + f"{power_panels[0].pk},Power Panel 7", + f"{power_panels[1].pk},Power Panel 8", + f"{power_panels[2].pk},Power Panel 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'location': locations[1].pk, @@ -2888,11 +3104,12 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Rack.objects.bulk_create(racks) - PowerFeed.objects.bulk_create(( + power_feeds = ( PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), - )) + ) + PowerFeed.objects.bulk_create(power_feeds) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2918,6 +3135,13 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80", ) + cls.csv_update_data = ( + "id,name,status", + f"{power_feeds[0].pk},Power Feed 7,{PowerFeedStatusChoices.STATUS_PLANNED}", + f"{power_feeds[1].pk},Power Feed 8,{PowerFeedStatusChoices.STATUS_PLANNED}", + f"{power_feeds[2].pk},Power Feed 9,{PowerFeedStatusChoices.STATUS_PLANNED}", + ) + cls.bulk_edit_data = { 'power_panel': powerpanels[1].pk, 'rack': racks[1].pk, @@ -2953,3 +3177,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) self.assertHttpStatus(response, 200) + + +class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VirtualDeviceContext + + @classmethod + def setUpTestData(cls): + devices = [create_test_device(name='Device 1')] + + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 4', + 'identifier': 4, + 'primary_ip4': None, + 'primary_ip6': None, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "device,status,name,identifier", + "Device 1,active,VDC 5,5", + "Device 1,active,VDC 6,6", + "Device 1,active,VDC 7,7", + ) + + cls.csv_update_data = ( + "id,status", + f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + ) + + cls.bulk_edit_data = { + 'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE, + } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c11a92a99..6772f96ad 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'dcim' urlpatterns = [ @@ -13,10 +12,7 @@ urlpatterns = [ path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), - path('regions//', views.RegionView.as_view(), name='region'), - path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), - path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), - path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path('regions//', include(get_model_urls('dcim', 'region'))), # Site groups path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), @@ -24,10 +20,7 @@ urlpatterns = [ path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), - path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), - path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), - path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), - path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + path('site-groups//', include(get_model_urls('dcim', 'sitegroup'))), # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), @@ -35,11 +28,7 @@ urlpatterns = [ path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path('sites//', views.SiteView.as_view(), name='site'), - path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), + path('sites//', include(get_model_urls('dcim', 'site'))), # Locations path('locations/', views.LocationListView.as_view(), name='location_list'), @@ -47,10 +36,7 @@ urlpatterns = [ path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), - path('locations//', views.LocationView.as_view(), name='location'), - path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), - path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), - path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//', include(get_model_urls('dcim', 'location'))), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -58,10 +44,7 @@ urlpatterns = [ path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), - path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), - path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), - path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), - path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path('rack-roles//', include(get_model_urls('dcim', 'rackrole'))), # Rack reservations path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), @@ -69,11 +52,7 @@ urlpatterns = [ path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), - path('rack-reservations//', views.RackReservationView.as_view(), name='rackreservation'), - path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), - path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), - path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}), + path('rack-reservations//', include(get_model_urls('dcim', 'rackreservation'))), # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), @@ -82,11 +61,7 @@ urlpatterns = [ path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - path('racks//', views.RackView.as_view(), name='rack'), - path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), - path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), - path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), + path('racks//', include(get_model_urls('dcim', 'rack'))), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), @@ -94,10 +69,7 @@ urlpatterns = [ path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), - path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//', include(get_model_urls('dcim', 'manufacturer'))), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -105,21 +77,7 @@ urlpatterns = [ path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), - path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), - path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), - path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), - path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), - path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), - path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), - path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), - path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'), - path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), - path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'), - path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), - path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), - path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), + path('device-types//', include(get_model_urls('dcim', 'devicetype'))), # Module types path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'), @@ -127,98 +85,77 @@ urlpatterns = [ path('module-types/import/', views.ModuleTypeImportView.as_view(), name='moduletype_import'), path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'), path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'), - path('module-types//', views.ModuleTypeView.as_view(), name='moduletype'), - path('module-types//console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'), - path('module-types//console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'), - path('module-types//power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'), - path('module-types//power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'), - path('module-types//interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'), - path('module-types//front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'), - path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'), - path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'), - path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'), - path('module-types//changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}), - path('module-types//journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}), + path('module-types//', include(get_model_urls('dcim', 'moduletype'))), # Console port templates path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'), path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), - path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), - path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'), + path('console-port-templates//', include(get_model_urls('dcim', 'consoleporttemplate'))), # Console server port templates path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'), path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), - path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), - path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'), + path('console-server-port-templates//', include(get_model_urls('dcim', 'consoleserverporttemplate'))), # Power port templates path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'), path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), - path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), - path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'), + path('power-port-templates//', include(get_model_urls('dcim', 'powerporttemplate'))), # Power outlet templates path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'), path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), - path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), - path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'), + path('power-outlet-templates//', include(get_model_urls('dcim', 'poweroutlettemplate'))), # Interface templates path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'), path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), - path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), - path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'), + path('interface-templates//', include(get_model_urls('dcim', 'interfacetemplate'))), # Front port templates path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'), path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), - path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), - path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'), + path('front-port-templates//', include(get_model_urls('dcim', 'frontporttemplate'))), # Rear port templates path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'), path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), - path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), - path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'), + path('rear-port-templates//', include(get_model_urls('dcim', 'rearporttemplate'))), # Device bay templates path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), - path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), - path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), + path('device-bay-templates//', include(get_model_urls('dcim', 'devicebaytemplate'))), # Module bay templates path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'), - path('module-bay-templates//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), - path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'), + path('module-bay-templates//', include(get_model_urls('dcim', 'modulebaytemplate'))), # Inventory item templates path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'), path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'), path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'), path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'), - path('inventory-item-templates//edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'), - path('inventory-item-templates//delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'), + path('inventory-item-templates//', include(get_model_urls('dcim', 'inventoryitemtemplate'))), # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), @@ -226,10 +163,7 @@ urlpatterns = [ path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), - path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//', include(get_model_urls('dcim', 'devicerole'))), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), @@ -237,10 +171,7 @@ urlpatterns = [ path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path('platforms//', views.PlatformView.as_view(), name='platform'), - path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//', include(get_model_urls('dcim', 'platform'))), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), @@ -250,25 +181,15 @@ urlpatterns = [ path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'), path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - path('devices//', views.DeviceView.as_view(), name='device'), - path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), - path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), - path('devices//console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'), - path('devices//console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'), - path('devices//power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'), - path('devices//power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'), - path('devices//interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'), - path('devices//front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'), - path('devices//rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'), - path('devices//module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'), - path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), - path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), - path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}), - path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), - path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path('devices//', include(get_model_urls('dcim', 'device'))), + + # Virtual Device Context + path('virtual-device-contexts/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'), + path('virtual-device-contexts/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'), + path('virtual-device-contexts/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'), + path('virtual-device-contexts/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'), + path('virtual-device-contexts/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'), + path('virtual-device-contexts//', include(get_model_urls('dcim', 'virtualdevicecontext'))), # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), @@ -276,11 +197,7 @@ urlpatterns = [ path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'), path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'), path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'), - path('modules//', views.ModuleView.as_view(), name='module'), - path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'), - path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'), - path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}), - path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}), + path('modules//', include(get_model_urls('dcim', 'module'))), # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), @@ -290,11 +207,7 @@ urlpatterns = [ path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'), path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'), path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), - path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), - path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//', include(get_model_urls('dcim', 'consoleport'))), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -305,11 +218,7 @@ urlpatterns = [ path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'), - path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), - path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), - path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//', include(get_model_urls('dcim', 'consoleserverport'))), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -320,11 +229,7 @@ urlpatterns = [ path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'), path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'), path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path('power-ports//', views.PowerPortView.as_view(), name='powerport'), - path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), - path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), - path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//', include(get_model_urls('dcim', 'powerport'))), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -335,11 +240,7 @@ urlpatterns = [ path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'), - path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), - path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), - path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//', include(get_model_urls('dcim', 'poweroutlet'))), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -350,11 +251,7 @@ urlpatterns = [ path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path('interfaces//', views.InterfaceView.as_view(), name='interface'), - path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//', include(get_model_urls('dcim', 'interface'))), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -365,11 +262,7 @@ urlpatterns = [ path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path('front-ports//', views.FrontPortView.as_view(), name='frontport'), - path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), - path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), - path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//', include(get_model_urls('dcim', 'frontport'))), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -380,11 +273,7 @@ urlpatterns = [ path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path('rear-ports//', views.RearPortView.as_view(), name='rearport'), - path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), - path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), - path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), - path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//', include(get_model_urls('dcim', 'rearport'))), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Module bays @@ -394,10 +283,7 @@ urlpatterns = [ path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'), path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'), path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'), - path('module-bays//', views.ModuleBayView.as_view(), name='modulebay'), - path('module-bays//edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'), - path('module-bays//delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'), - path('module-bays//changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}), + path('module-bays//', include(get_model_urls('dcim', 'modulebay'))), path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'), # Device bays @@ -407,12 +293,7 @@ urlpatterns = [ path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'), path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'), - path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}), - path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), - path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path('device-bays//', include(get_model_urls('dcim', 'devicebay'))), path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), # Inventory items @@ -422,22 +303,16 @@ urlpatterns = [ path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'), path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), - path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), - path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), - path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), + path('inventory-items//', include(get_model_urls('dcim', 'inventoryitem'))), path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'), - # Device roles + # Inventory item roles path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'), path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'), path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'), path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'), path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'), - path('inventory-item-roles//', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'), - path('inventory-item-roles//edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'), - path('inventory-item-roles//delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'), - path('inventory-item-roles//changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}), + path('inventory-item-roles//', include(get_model_urls('dcim', 'inventoryitemrole'))), # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), @@ -445,11 +320,7 @@ urlpatterns = [ path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), - path('cables//', views.CableView.as_view(), name='cable'), - path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), - path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), - path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), - path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), + path('cables//', include(get_model_urls('dcim', 'cable'))), # Console/power/interface connections (read-only) path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), @@ -462,12 +333,7 @@ urlpatterns = [ path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'), path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'), path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'), - path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'), - path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), - path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), - path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path('virtual-chassis//', include(get_model_urls('dcim', 'virtualchassis'))), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), # Power panels @@ -476,11 +342,7 @@ urlpatterns = [ path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), - path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), - path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), - path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), - path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), - path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}), + path('power-panels//', include(get_model_urls('dcim', 'powerpanel'))), # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), @@ -489,11 +351,6 @@ urlpatterns = [ path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), - path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), - path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), - path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), - path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), - path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), - path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), + path('power-feeds//', include(get_model_urls('dcim', 'powerfeed'))), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index edc9a5ce8..06a486534 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination @@ -19,7 +20,7 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model from utilities.utils import count_related -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine from . import filtersets, forms, tables from .choices import DeviceFaceChoices @@ -45,11 +46,6 @@ class DeviceComponentsView(generic.ObjectChildrenView): def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(device=parent) - def get_extra_context(self, request, instance): - return { - 'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}", - } - class DeviceTypeComponentsView(DeviceComponentsView): queryset = DeviceType.objects.all() @@ -60,10 +56,9 @@ class DeviceTypeComponentsView(DeviceComponentsView): return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent) def get_extra_context(self, request, instance): - context = super().get_extra_context(request, instance) - context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk}) - - return context + return { + 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), + } class ModuleTypeComponentsView(DeviceComponentsView): @@ -75,10 +70,9 @@ class ModuleTypeComponentsView(DeviceComponentsView): return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent) def get_extra_context(self, request, instance): - context = super().get_extra_context(request, instance) - context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk}) - - return context + return { + 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), + } class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): @@ -140,6 +134,61 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) }) +class PathTraceView(generic.ObjectView): + """ + Trace a cable path beginning from the given path endpoint (origin). + """ + additional_permissions = ['dcim.view_cable'] + template_name = 'dcim/cable_trace.html' + + def dispatch(self, request, *args, **kwargs): + model = kwargs.pop('model') + self.queryset = model.objects.all() + + return super().dispatch(request, *args, **kwargs) + + def get_extra_context(self, request, instance): + related_paths = [] + + # If tracing a PathEndpoint, locate the CablePath (if one exists) by its origin + if isinstance(instance, PathEndpoint): + path = instance._path + + # Otherwise, find all CablePaths which traverse the specified object + else: + related_paths = CablePath.objects.filter(_nodes__contains=instance) + # Check for specification of a particular path (when tracing pass-through ports) + try: + path_id = int(request.GET.get('cablepath_id')) + except TypeError: + path_id = None + if path_id in list(related_paths.values_list('pk', flat=True)): + path = CablePath.objects.get(pk=path_id) + else: + path = related_paths.first() + + # No paths found + if path is None: + return { + 'path': None + } + + # Get the total length of the cable and whether the length is definitive (fully defined) + total_length, is_definitive = path.get_total_length() if path else (None, False) + + # Determine the path to the SVG trace image + api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" + svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" + + return { + 'path': path, + 'related_paths': related_paths, + 'total_length': total_length, + 'is_definitive': is_definitive, + 'svg_url': svg_url, + } + + # # Regions # @@ -157,6 +206,7 @@ class RegionListView(generic.ObjectListView): table = tables.RegionTable +@register_model_view(Region) class RegionView(generic.ObjectView): queryset = Region.objects.all() @@ -185,18 +235,20 @@ class RegionView(generic.ObjectView): } +@register_model_view(Region, 'edit') class RegionEditView(generic.ObjectEditView): queryset = Region.objects.all() form = forms.RegionForm +@register_model_view(Region, 'delete') class RegionDeleteView(generic.ObjectDeleteView): queryset = Region.objects.all() class RegionBulkImportView(generic.BulkImportView): queryset = Region.objects.all() - model_form = forms.RegionCSVForm + model_form = forms.RegionImportForm table = tables.RegionTable @@ -242,6 +294,7 @@ class SiteGroupListView(generic.ObjectListView): table = tables.SiteGroupTable +@register_model_view(SiteGroup) class SiteGroupView(generic.ObjectView): queryset = SiteGroup.objects.all() @@ -270,18 +323,20 @@ class SiteGroupView(generic.ObjectView): } +@register_model_view(SiteGroup, 'edit') class SiteGroupEditView(generic.ObjectEditView): queryset = SiteGroup.objects.all() form = forms.SiteGroupForm +@register_model_view(SiteGroup, 'delete') class SiteGroupDeleteView(generic.ObjectDeleteView): queryset = SiteGroup.objects.all() class SiteGroupBulkImportView(generic.BulkImportView): queryset = SiteGroup.objects.all() - model_form = forms.SiteGroupCSVForm + model_form = forms.SiteGroupImportForm table = tables.SiteGroupTable @@ -321,6 +376,7 @@ class SiteListView(generic.ObjectListView): table = tables.SiteTable +@register_model_view(Site) class SiteView(generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') @@ -373,18 +429,20 @@ class SiteView(generic.ObjectView): } +@register_model_view(Site, 'edit') class SiteEditView(generic.ObjectEditView): queryset = Site.objects.all() form = forms.SiteForm +@register_model_view(Site, 'delete') class SiteDeleteView(generic.ObjectDeleteView): queryset = Site.objects.all() class SiteBulkImportView(generic.BulkImportView): queryset = Site.objects.all() - model_form = forms.SiteCSVForm + model_form = forms.SiteImportForm table = tables.SiteTable @@ -424,6 +482,7 @@ class LocationListView(generic.ObjectListView): table = tables.LocationTable +@register_model_view(Location) class LocationView(generic.ObjectView): queryset = Location.objects.all() @@ -463,18 +522,20 @@ class LocationView(generic.ObjectView): } +@register_model_view(Location, 'edit') class LocationEditView(generic.ObjectEditView): queryset = Location.objects.all() form = forms.LocationForm +@register_model_view(Location, 'delete') class LocationDeleteView(generic.ObjectDeleteView): queryset = Location.objects.all() class LocationBulkImportView(generic.BulkImportView): queryset = Location.objects.all() - model_form = forms.LocationCSVForm + model_form = forms.LocationImportForm table = tables.LocationTable @@ -516,6 +577,7 @@ class RackRoleListView(generic.ObjectListView): table = tables.RackRoleTable +@register_model_view(RackRole) class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() @@ -534,18 +596,20 @@ class RackRoleView(generic.ObjectView): } +@register_model_view(RackRole, 'edit') class RackRoleEditView(generic.ObjectEditView): queryset = RackRole.objects.all() form = forms.RackRoleForm +@register_model_view(RackRole, 'delete') class RackRoleDeleteView(generic.ObjectDeleteView): queryset = RackRole.objects.all() class RackRoleBulkImportView(generic.BulkImportView): queryset = RackRole.objects.all() - model_form = forms.RackRoleCSVForm + model_form = forms.RackRoleImportForm table = tables.RackRoleTable @@ -630,6 +694,7 @@ class RackElevationListView(generic.ObjectListView): }) +@register_model_view(Rack) class RackView(generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') @@ -673,19 +738,21 @@ class RackView(generic.ObjectView): } +@register_model_view(Rack, 'edit') class RackEditView(generic.ObjectEditView): queryset = Rack.objects.all() form = forms.RackForm template_name = 'dcim/rack_edit.html' +@register_model_view(Rack, 'delete') class RackDeleteView(generic.ObjectDeleteView): queryset = Rack.objects.all() class RackBulkImportView(generic.BulkImportView): queryset = Rack.objects.all() - model_form = forms.RackCSVForm + model_form = forms.RackImportForm table = tables.RackTable @@ -713,10 +780,12 @@ class RackReservationListView(generic.ObjectListView): table = tables.RackReservationTable +@register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() +@register_model_view(RackReservation, 'edit') class RackReservationEditView(generic.ObjectEditView): queryset = RackReservation.objects.all() form = forms.RackReservationForm @@ -729,20 +798,21 @@ class RackReservationEditView(generic.ObjectEditView): return obj +@register_model_view(RackReservation, 'delete') class RackReservationDeleteView(generic.ObjectDeleteView): queryset = RackReservation.objects.all() class RackReservationImportView(generic.BulkImportView): queryset = RackReservation.objects.all() - model_form = forms.RackReservationCSVForm + model_form = forms.RackReservationImportForm table = tables.RackReservationTable - def _save_obj(self, obj_form, request): + def save_object(self, object_form, request): """ Assign the currently authenticated user to the RackReservation. """ - instance = obj_form.save(commit=False) + instance = object_form.save(commit=False) instance.user = request.user instance.save() @@ -777,6 +847,7 @@ class ManufacturerListView(generic.ObjectListView): table = tables.ManufacturerTable +@register_model_view(Manufacturer) class ManufacturerView(generic.ObjectView): queryset = Manufacturer.objects.all() @@ -803,18 +874,20 @@ class ManufacturerView(generic.ObjectView): } +@register_model_view(Manufacturer, 'edit') class ManufacturerEditView(generic.ObjectEditView): queryset = Manufacturer.objects.all() form = forms.ManufacturerForm +@register_model_view(Manufacturer, 'delete') class ManufacturerDeleteView(generic.ObjectDeleteView): queryset = Manufacturer.objects.all() class ManufacturerBulkImportView(generic.BulkImportView): queryset = Manufacturer.objects.all() - model_form = forms.ManufacturerCSVForm + model_form = forms.ManufacturerImportForm table = tables.ManufacturerTable @@ -847,6 +920,7 @@ class DeviceTypeListView(generic.ObjectListView): table = tables.DeviceTypeTable +@register_model_view(DeviceType) class DeviceTypeView(generic.ObjectView): queryset = DeviceType.objects.all() @@ -858,86 +932,168 @@ class DeviceTypeView(generic.ObjectView): } -class DeviceTypeConsolePortsView(DeviceTypeComponentsView): - child_model = ConsolePortTemplate - table = tables.ConsolePortTemplateTable - filterset = filtersets.ConsolePortTemplateFilterSet - viewname = 'dcim:devicetype_consoleports' - - -class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): - child_model = ConsoleServerPortTemplate - table = tables.ConsoleServerPortTemplateTable - filterset = filtersets.ConsoleServerPortTemplateFilterSet - viewname = 'dcim:devicetype_consoleserverports' - - -class DeviceTypePowerPortsView(DeviceTypeComponentsView): - child_model = PowerPortTemplate - table = tables.PowerPortTemplateTable - filterset = filtersets.PowerPortTemplateFilterSet - viewname = 'dcim:devicetype_powerports' - - -class DeviceTypePowerOutletsView(DeviceTypeComponentsView): - child_model = PowerOutletTemplate - table = tables.PowerOutletTemplateTable - filterset = filtersets.PowerOutletTemplateFilterSet - viewname = 'dcim:devicetype_poweroutlets' - - -class DeviceTypeInterfacesView(DeviceTypeComponentsView): - child_model = InterfaceTemplate - table = tables.InterfaceTemplateTable - filterset = filtersets.InterfaceTemplateFilterSet - viewname = 'dcim:devicetype_interfaces' - - -class DeviceTypeFrontPortsView(DeviceTypeComponentsView): - child_model = FrontPortTemplate - table = tables.FrontPortTemplateTable - filterset = filtersets.FrontPortTemplateFilterSet - viewname = 'dcim:devicetype_frontports' - - -class DeviceTypeRearPortsView(DeviceTypeComponentsView): - child_model = RearPortTemplate - table = tables.RearPortTemplateTable - filterset = filtersets.RearPortTemplateFilterSet - viewname = 'dcim:devicetype_rearports' - - -class DeviceTypeModuleBaysView(DeviceTypeComponentsView): - child_model = ModuleBayTemplate - table = tables.ModuleBayTemplateTable - filterset = filtersets.ModuleBayTemplateFilterSet - viewname = 'dcim:devicetype_modulebays' - - -class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): - child_model = DeviceBayTemplate - table = tables.DeviceBayTemplateTable - filterset = filtersets.DeviceBayTemplateFilterSet - viewname = 'dcim:devicetype_devicebays' - - -class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): - child_model = InventoryItemTemplate - table = tables.InventoryItemTemplateTable - filterset = filtersets.InventoryItemTemplateFilterSet - viewname = 'dcim:devicetype_inventoryitems' - - +@register_model_view(DeviceType, 'edit') class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() form = forms.DeviceTypeForm +@register_model_view(DeviceType, 'delete') class DeviceTypeDeleteView(generic.ObjectDeleteView): queryset = DeviceType.objects.all() -class DeviceTypeImportView(generic.ObjectImportView): +@register_model_view(DeviceType, 'consoleports', path='console-ports') +class DeviceTypeConsolePortsView(DeviceTypeComponentsView): + child_model = ConsolePortTemplate + table = tables.ConsolePortTemplateTable + filterset = filtersets.ConsolePortTemplateFilterSet + viewname = 'dcim:devicetype_consoleports' + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleporttemplates.count(), + permission='dcim.view_consoleporttemplate', + weight=550, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'consoleserverports', path='console-server-ports') +class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): + child_model = ConsoleServerPortTemplate + table = tables.ConsoleServerPortTemplateTable + filterset = filtersets.ConsoleServerPortTemplateFilterSet + viewname = 'dcim:devicetype_consoleserverports' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverporttemplates.count(), + permission='dcim.view_consoleserverporttemplate', + weight=560, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'powerports', path='power-ports') +class DeviceTypePowerPortsView(DeviceTypeComponentsView): + child_model = PowerPortTemplate + table = tables.PowerPortTemplateTable + filterset = filtersets.PowerPortTemplateFilterSet + viewname = 'dcim:devicetype_powerports' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerporttemplates.count(), + permission='dcim.view_powerporttemplate', + weight=570, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'poweroutlets', path='power-outlets') +class DeviceTypePowerOutletsView(DeviceTypeComponentsView): + child_model = PowerOutletTemplate + table = tables.PowerOutletTemplateTable + filterset = filtersets.PowerOutletTemplateFilterSet + viewname = 'dcim:devicetype_poweroutlets' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlettemplates.count(), + permission='dcim.view_poweroutlettemplate', + weight=580, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'interfaces') +class DeviceTypeInterfacesView(DeviceTypeComponentsView): + child_model = InterfaceTemplate + table = tables.InterfaceTemplateTable + filterset = filtersets.InterfaceTemplateFilterSet + viewname = 'dcim:devicetype_interfaces' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfacetemplates.count(), + permission='dcim.view_interfacetemplate', + weight=520, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'frontports', path='front-ports') +class DeviceTypeFrontPortsView(DeviceTypeComponentsView): + child_model = FrontPortTemplate + table = tables.FrontPortTemplateTable + filterset = filtersets.FrontPortTemplateFilterSet + viewname = 'dcim:devicetype_frontports' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontporttemplates.count(), + permission='dcim.view_frontporttemplate', + weight=530, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'rearports', path='rear-ports') +class DeviceTypeRearPortsView(DeviceTypeComponentsView): + child_model = RearPortTemplate + table = tables.RearPortTemplateTable + filterset = filtersets.RearPortTemplateFilterSet + viewname = 'dcim:devicetype_rearports' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearporttemplates.count(), + permission='dcim.view_rearporttemplate', + weight=540, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'modulebays', path='module-bays') +class DeviceTypeModuleBaysView(DeviceTypeComponentsView): + child_model = ModuleBayTemplate + table = tables.ModuleBayTemplateTable + filterset = filtersets.ModuleBayTemplateFilterSet + viewname = 'dcim:devicetype_modulebays' + tab = ViewTab( + label=_('Module Bays'), + badge=lambda obj: obj.modulebaytemplates.count(), + permission='dcim.view_modulebaytemplate', + weight=510, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'devicebays', path='device-bays') +class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): + child_model = DeviceBayTemplate + table = tables.DeviceBayTemplateTable + filterset = filtersets.DeviceBayTemplateFilterSet + viewname = 'dcim:devicetype_devicebays' + tab = ViewTab( + label=_('Device Bays'), + badge=lambda obj: obj.devicebaytemplates.count(), + permission='dcim.view_devicebaytemplate', + weight=500, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'inventoryitems', path='inventory-items') +class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): + child_model = InventoryItemTemplate + table = tables.InventoryItemTemplateTable + filterset = filtersets.InventoryItemTemplateFilterSet + viewname = 'dcim:devicetype_inventoryitems' + tab = ViewTab( + label=_('Inventory Items'), + badge=lambda obj: obj.inventoryitemtemplates.count(), + permission='dcim.view_invenotryitemtemplate', + weight=590, + hide_if_empty=True + ) + + +class DeviceTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', @@ -953,6 +1109,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm + table = tables.DeviceTypeTable related_object_forms = { 'console-ports': forms.ConsolePortTemplateImportForm, 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, @@ -1001,6 +1158,7 @@ class ModuleTypeListView(generic.ObjectListView): table = tables.ModuleTypeTable +@register_model_view(ModuleType) class ModuleTypeView(generic.ObjectView): queryset = ModuleType.objects.all() @@ -1012,65 +1170,123 @@ class ModuleTypeView(generic.ObjectView): } -class ModuleTypeConsolePortsView(ModuleTypeComponentsView): - child_model = ConsolePortTemplate - table = tables.ConsolePortTemplateTable - filterset = filtersets.ConsolePortTemplateFilterSet - viewname = 'dcim:moduletype_consoleports' - - -class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView): - child_model = ConsoleServerPortTemplate - table = tables.ConsoleServerPortTemplateTable - filterset = filtersets.ConsoleServerPortTemplateFilterSet - viewname = 'dcim:moduletype_consoleserverports' - - -class ModuleTypePowerPortsView(ModuleTypeComponentsView): - child_model = PowerPortTemplate - table = tables.PowerPortTemplateTable - filterset = filtersets.PowerPortTemplateFilterSet - viewname = 'dcim:moduletype_powerports' - - -class ModuleTypePowerOutletsView(ModuleTypeComponentsView): - child_model = PowerOutletTemplate - table = tables.PowerOutletTemplateTable - filterset = filtersets.PowerOutletTemplateFilterSet - viewname = 'dcim:moduletype_poweroutlets' - - -class ModuleTypeInterfacesView(ModuleTypeComponentsView): - child_model = InterfaceTemplate - table = tables.InterfaceTemplateTable - filterset = filtersets.InterfaceTemplateFilterSet - viewname = 'dcim:moduletype_interfaces' - - -class ModuleTypeFrontPortsView(ModuleTypeComponentsView): - child_model = FrontPortTemplate - table = tables.FrontPortTemplateTable - filterset = filtersets.FrontPortTemplateFilterSet - viewname = 'dcim:moduletype_frontports' - - -class ModuleTypeRearPortsView(ModuleTypeComponentsView): - child_model = RearPortTemplate - table = tables.RearPortTemplateTable - filterset = filtersets.RearPortTemplateFilterSet - viewname = 'dcim:moduletype_rearports' - - +@register_model_view(ModuleType, 'edit') class ModuleTypeEditView(generic.ObjectEditView): queryset = ModuleType.objects.all() form = forms.ModuleTypeForm +@register_model_view(ModuleType, 'delete') class ModuleTypeDeleteView(generic.ObjectDeleteView): queryset = ModuleType.objects.all() -class ModuleTypeImportView(generic.ObjectImportView): +@register_model_view(ModuleType, 'consoleports', path='console-ports') +class ModuleTypeConsolePortsView(ModuleTypeComponentsView): + child_model = ConsolePortTemplate + table = tables.ConsolePortTemplateTable + filterset = filtersets.ConsolePortTemplateFilterSet + viewname = 'dcim:moduletype_consoleports' + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleporttemplates.count(), + permission='dcim.view_consoleporttemplate', + weight=530, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'consoleserverports', path='console-server-ports') +class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView): + child_model = ConsoleServerPortTemplate + table = tables.ConsoleServerPortTemplateTable + filterset = filtersets.ConsoleServerPortTemplateFilterSet + viewname = 'dcim:moduletype_consoleserverports' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverporttemplates.count(), + permission='dcim.view_consoleserverporttemplate', + weight=540, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'powerports', path='power-ports') +class ModuleTypePowerPortsView(ModuleTypeComponentsView): + child_model = PowerPortTemplate + table = tables.PowerPortTemplateTable + filterset = filtersets.PowerPortTemplateFilterSet + viewname = 'dcim:moduletype_powerports' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerporttemplates.count(), + permission='dcim.view_powerporttemplate', + weight=550, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'poweroutlets', path='power-outlets') +class ModuleTypePowerOutletsView(ModuleTypeComponentsView): + child_model = PowerOutletTemplate + table = tables.PowerOutletTemplateTable + filterset = filtersets.PowerOutletTemplateFilterSet + viewname = 'dcim:moduletype_poweroutlets' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlettemplates.count(), + permission='dcim.view_poweroutlettemplate', + weight=560, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'interfaces') +class ModuleTypeInterfacesView(ModuleTypeComponentsView): + child_model = InterfaceTemplate + table = tables.InterfaceTemplateTable + filterset = filtersets.InterfaceTemplateFilterSet + viewname = 'dcim:moduletype_interfaces' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfacetemplates.count(), + permission='dcim.view_interfacetemplate', + weight=500, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'frontports', path='front-ports') +class ModuleTypeFrontPortsView(ModuleTypeComponentsView): + child_model = FrontPortTemplate + table = tables.FrontPortTemplateTable + filterset = filtersets.FrontPortTemplateFilterSet + viewname = 'dcim:moduletype_frontports' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontporttemplates.count(), + permission='dcim.view_frontporttemplate', + weight=510, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'rearports', path='rear-ports') +class ModuleTypeRearPortsView(ModuleTypeComponentsView): + child_model = RearPortTemplate + table = tables.RearPortTemplateTable + filterset = filtersets.RearPortTemplateFilterSet + viewname = 'dcim:moduletype_rearports' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearporttemplates.count(), + permission='dcim.view_rearporttemplate', + weight=520, + hide_if_empty=True + ) + + +class ModuleTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_moduletype', 'dcim.add_consoleporttemplate', @@ -1083,6 +1299,7 @@ class ModuleTypeImportView(generic.ObjectImportView): ] queryset = ModuleType.objects.all() model_form = forms.ModuleTypeImportForm + table = tables.ModuleTypeTable related_object_forms = { 'console-ports': forms.ConsolePortTemplateImportForm, 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, @@ -1125,11 +1342,13 @@ class ConsolePortTemplateCreateView(generic.ComponentCreateView): model_form = forms.ConsolePortTemplateForm +@register_model_view(ConsolePortTemplate, 'edit') class ConsolePortTemplateEditView(generic.ObjectEditView): queryset = ConsolePortTemplate.objects.all() form = forms.ConsolePortTemplateForm +@register_model_view(ConsolePortTemplate, 'delete') class ConsolePortTemplateDeleteView(generic.ObjectDeleteView): queryset = ConsolePortTemplate.objects.all() @@ -1159,11 +1378,13 @@ class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): model_form = forms.ConsoleServerPortTemplateForm +@register_model_view(ConsoleServerPortTemplate, 'edit') class ConsoleServerPortTemplateEditView(generic.ObjectEditView): queryset = ConsoleServerPortTemplate.objects.all() form = forms.ConsoleServerPortTemplateForm +@register_model_view(ConsoleServerPortTemplate, 'delete') class ConsoleServerPortTemplateDeleteView(generic.ObjectDeleteView): queryset = ConsoleServerPortTemplate.objects.all() @@ -1193,11 +1414,13 @@ class PowerPortTemplateCreateView(generic.ComponentCreateView): model_form = forms.PowerPortTemplateForm +@register_model_view(PowerPortTemplate, 'edit') class PowerPortTemplateEditView(generic.ObjectEditView): queryset = PowerPortTemplate.objects.all() form = forms.PowerPortTemplateForm +@register_model_view(PowerPortTemplate, 'delete') class PowerPortTemplateDeleteView(generic.ObjectDeleteView): queryset = PowerPortTemplate.objects.all() @@ -1227,11 +1450,13 @@ class PowerOutletTemplateCreateView(generic.ComponentCreateView): model_form = forms.PowerOutletTemplateForm +@register_model_view(PowerOutletTemplate, 'edit') class PowerOutletTemplateEditView(generic.ObjectEditView): queryset = PowerOutletTemplate.objects.all() form = forms.PowerOutletTemplateForm +@register_model_view(PowerOutletTemplate, 'delete') class PowerOutletTemplateDeleteView(generic.ObjectDeleteView): queryset = PowerOutletTemplate.objects.all() @@ -1261,11 +1486,13 @@ class InterfaceTemplateCreateView(generic.ComponentCreateView): model_form = forms.InterfaceTemplateForm +@register_model_view(InterfaceTemplate, 'edit') class InterfaceTemplateEditView(generic.ObjectEditView): queryset = InterfaceTemplate.objects.all() form = forms.InterfaceTemplateForm +@register_model_view(InterfaceTemplate, 'delete') class InterfaceTemplateDeleteView(generic.ObjectDeleteView): queryset = InterfaceTemplate.objects.all() @@ -1295,11 +1522,13 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView): model_form = forms.FrontPortTemplateForm +@register_model_view(FrontPortTemplate, 'edit') class FrontPortTemplateEditView(generic.ObjectEditView): queryset = FrontPortTemplate.objects.all() form = forms.FrontPortTemplateForm +@register_model_view(FrontPortTemplate, 'delete') class FrontPortTemplateDeleteView(generic.ObjectDeleteView): queryset = FrontPortTemplate.objects.all() @@ -1329,11 +1558,13 @@ class RearPortTemplateCreateView(generic.ComponentCreateView): model_form = forms.RearPortTemplateForm +@register_model_view(RearPortTemplate, 'edit') class RearPortTemplateEditView(generic.ObjectEditView): queryset = RearPortTemplate.objects.all() form = forms.RearPortTemplateForm +@register_model_view(RearPortTemplate, 'delete') class RearPortTemplateDeleteView(generic.ObjectDeleteView): queryset = RearPortTemplate.objects.all() @@ -1363,11 +1594,13 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView): model_form = forms.ModuleBayTemplateForm +@register_model_view(ModuleBayTemplate, 'edit') class ModuleBayTemplateEditView(generic.ObjectEditView): queryset = ModuleBayTemplate.objects.all() form = forms.ModuleBayTemplateForm +@register_model_view(ModuleBayTemplate, 'delete') class ModuleBayTemplateDeleteView(generic.ObjectDeleteView): queryset = ModuleBayTemplate.objects.all() @@ -1397,11 +1630,13 @@ class DeviceBayTemplateCreateView(generic.ComponentCreateView): model_form = forms.DeviceBayTemplateForm +@register_model_view(DeviceBayTemplate, 'edit') class DeviceBayTemplateEditView(generic.ObjectEditView): queryset = DeviceBayTemplate.objects.all() form = forms.DeviceBayTemplateForm +@register_model_view(DeviceBayTemplate, 'delete') class DeviceBayTemplateDeleteView(generic.ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() @@ -1442,11 +1677,13 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView): return instance +@register_model_view(InventoryItemTemplate, 'edit') class InventoryItemTemplateEditView(generic.ObjectEditView): queryset = InventoryItemTemplate.objects.all() form = forms.InventoryItemTemplateForm +@register_model_view(InventoryItemTemplate, 'delete') class InventoryItemTemplateDeleteView(generic.ObjectDeleteView): queryset = InventoryItemTemplate.objects.all() @@ -1480,6 +1717,7 @@ class DeviceRoleListView(generic.ObjectListView): table = tables.DeviceRoleTable +@register_model_view(DeviceRole) class DeviceRoleView(generic.ObjectView): queryset = DeviceRole.objects.all() @@ -1497,18 +1735,20 @@ class DeviceRoleView(generic.ObjectView): } +@register_model_view(DeviceRole, 'edit') class DeviceRoleEditView(generic.ObjectEditView): queryset = DeviceRole.objects.all() form = forms.DeviceRoleForm +@register_model_view(DeviceRole, 'delete') class DeviceRoleDeleteView(generic.ObjectDeleteView): queryset = DeviceRole.objects.all() class DeviceRoleBulkImportView(generic.BulkImportView): queryset = DeviceRole.objects.all() - model_form = forms.DeviceRoleCSVForm + model_form = forms.DeviceRoleImportForm table = tables.DeviceRoleTable @@ -1544,6 +1784,7 @@ class PlatformListView(generic.ObjectListView): filterset_form = forms.PlatformFilterForm +@register_model_view(Platform) class PlatformView(generic.ObjectView): queryset = Platform.objects.all() @@ -1560,18 +1801,20 @@ class PlatformView(generic.ObjectView): } +@register_model_view(Platform, 'edit') class PlatformEditView(generic.ObjectEditView): queryset = Platform.objects.all() form = forms.PlatformForm +@register_model_view(Platform, 'delete') class PlatformDeleteView(generic.ObjectDeleteView): queryset = Platform.objects.all() class PlatformBulkImportView(generic.BulkImportView): queryset = Platform.objects.all() - model_form = forms.PlatformCSVForm + model_form = forms.PlatformImportForm table = tables.PlatformTable @@ -1599,6 +1842,7 @@ class DeviceListView(generic.ObjectListView): template_name = 'dcim/device_list.html' +@register_model_view(Device) class DeviceView(generic.ObjectView): queryset = Device.objects.all() @@ -1611,49 +1855,104 @@ class DeviceView(generic.ObjectView): else: vc_members = [] - # Services services = Service.objects.restrict(request.user, 'view').filter(device=instance) + vdcs = VirtualDeviceContext.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( + 'tenant' + ) return { 'services': services, + 'vdcs': vdcs, 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}' } +@register_model_view(Device, 'edit') +class DeviceEditView(generic.ObjectEditView): + queryset = Device.objects.all() + form = forms.DeviceForm + template_name = 'dcim/device_edit.html' + + +@register_model_view(Device, 'delete') +class DeviceDeleteView(generic.ObjectDeleteView): + queryset = Device.objects.all() + + +@register_model_view(Device, 'consoleports', path='console-ports') class DeviceConsolePortsView(DeviceComponentsView): child_model = ConsolePort table = tables.DeviceConsolePortTable filterset = filtersets.ConsolePortFilterSet - template_name = 'dcim/device/consoleports.html' + template_name = 'dcim/device/consoleports.html', + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleports.count(), + permission='dcim.view_consoleport', + weight=550, + hide_if_empty=True + ) +@register_model_view(Device, 'consoleserverports', path='console-server-ports') class DeviceConsoleServerPortsView(DeviceComponentsView): child_model = ConsoleServerPort table = tables.DeviceConsoleServerPortTable filterset = filtersets.ConsoleServerPortFilterSet template_name = 'dcim/device/consoleserverports.html' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverports.count(), + permission='dcim.view_consoleserverport', + weight=560, + hide_if_empty=True + ) +@register_model_view(Device, 'powerports', path='power-ports') class DevicePowerPortsView(DeviceComponentsView): child_model = PowerPort table = tables.DevicePowerPortTable filterset = filtersets.PowerPortFilterSet template_name = 'dcim/device/powerports.html' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerports.count(), + permission='dcim.view_powerport', + weight=570, + hide_if_empty=True + ) +@register_model_view(Device, 'poweroutlets', path='power-outlets') class DevicePowerOutletsView(DeviceComponentsView): child_model = PowerOutlet table = tables.DevicePowerOutletTable filterset = filtersets.PowerOutletFilterSet template_name = 'dcim/device/poweroutlets.html' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlets.count(), + permission='dcim.view_poweroutlet', + weight=580, + hide_if_empty=True + ) +@register_model_view(Device, 'interfaces') class DeviceInterfacesView(DeviceComponentsView): child_model = Interface table = tables.DeviceInterfaceTable filterset = filtersets.InterfaceFilterSet template_name = 'dcim/device/interfaces.html' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfaces.count(), + permission='dcim.view_interface', + weight=520, + hide_if_empty=True + ) def get_children(self, request, parent): return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related( @@ -1662,112 +1961,107 @@ class DeviceInterfacesView(DeviceComponentsView): ) +@register_model_view(Device, 'frontports', path='front-ports') class DeviceFrontPortsView(DeviceComponentsView): child_model = FrontPort table = tables.DeviceFrontPortTable filterset = filtersets.FrontPortFilterSet template_name = 'dcim/device/frontports.html' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontports.count(), + permission='dcim.view_frontport', + weight=530, + hide_if_empty=True + ) +@register_model_view(Device, 'rearports', path='rear-ports') class DeviceRearPortsView(DeviceComponentsView): child_model = RearPort table = tables.DeviceRearPortTable filterset = filtersets.RearPortFilterSet template_name = 'dcim/device/rearports.html' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearports.count(), + permission='dcim.view_rearport', + weight=540, + hide_if_empty=True + ) +@register_model_view(Device, 'modulebays', path='module-bays') class DeviceModuleBaysView(DeviceComponentsView): child_model = ModuleBay table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet template_name = 'dcim/device/modulebays.html' + tab = ViewTab( + label=_('Module Bays'), + badge=lambda obj: obj.modulebays.count(), + permission='dcim.view_modulebay', + weight=510, + hide_if_empty=True + ) +@register_model_view(Device, 'devicebays', path='device-bays') class DeviceDeviceBaysView(DeviceComponentsView): child_model = DeviceBay table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet template_name = 'dcim/device/devicebays.html' + tab = ViewTab( + label=_('Device Bays'), + badge=lambda obj: obj.devicebays.count(), + permission='dcim.view_devicebay', + weight=500, + hide_if_empty=True + ) +@register_model_view(Device, 'inventory') class DeviceInventoryView(DeviceComponentsView): child_model = InventoryItem table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet template_name = 'dcim/device/inventory.html' + tab = ViewTab( + label=_('Inventory Items'), + badge=lambda obj: obj.inventoryitems.count(), + permission='dcim.view_inventoryitem', + weight=590, + hide_if_empty=True + ) -class DeviceStatusView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] - queryset = Device.objects.all() - template_name = 'dcim/device/status.html' - - def get_extra_context(self, request, instance): - return { - 'active_tab': 'status', - } - - -class DeviceLLDPNeighborsView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] - queryset = Device.objects.all() - template_name = 'dcim/device/lldp_neighbors.html' - - def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( - '_path' - ).exclude( - type__in=NONCONNECTABLE_IFACE_TYPES - ) - - return { - 'interfaces': interfaces, - 'active_tab': 'lldp-neighbors', - } - - -class DeviceConfigView(generic.ObjectView): - additional_permissions = ['dcim.napalm_read_device'] - queryset = Device.objects.all() - template_name = 'dcim/device/config.html' - - def get_extra_context(self, request, instance): - return { - 'active_tab': 'config', - } - - +@register_model_view(Device, 'configcontext', path='config-context') class DeviceConfigContextView(ObjectConfigContextView): queryset = Device.objects.annotate_config_context_data() base_template = 'dcim/device/base.html' - - -class DeviceEditView(generic.ObjectEditView): - queryset = Device.objects.all() - form = forms.DeviceForm - template_name = 'dcim/device_edit.html' - - -class DeviceDeleteView(generic.ObjectDeleteView): - queryset = Device.objects.all() + tab = ViewTab( + label=_('Config Context'), + permission='extras.view_configcontext', + weight=2000 + ) class DeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() - model_form = forms.DeviceCSVForm + model_form = forms.DeviceImportForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' class ChildDeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() - model_form = forms.ChildDeviceCSVForm + model_form = forms.ChildDeviceImportForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' - def _save_obj(self, obj_form, request): - - obj = obj_form.save() + def save_object(self, object_form, request): + obj = object_form.save() # Save the reverse relation to the parent device bay device_bay = obj.parent_bay @@ -1797,7 +2091,72 @@ class DeviceBulkRenameView(generic.BulkRenameView): # -# Devices +# Device NAPALM views +# + +class NAPALMViewTab(ViewTab): + + def render(self, instance): + # Display NAPALM tabs only for devices which meet certain requirements + if not ( + instance.status == 'active' and + instance.primary_ip and + instance.platform and + instance.platform.napalm_driver + ): + return None + return super().render(instance) + + +@register_model_view(Device, 'status') +class DeviceStatusView(generic.ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() + template_name = 'dcim/device/status.html' + tab = NAPALMViewTab( + label=_('Status'), + permission='dcim.napalm_read_device', + weight=3000 + ) + + +@register_model_view(Device, 'lldp_neighbors', path='lldp-neighbors') +class DeviceLLDPNeighborsView(generic.ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() + template_name = 'dcim/device/lldp_neighbors.html' + tab = NAPALMViewTab( + label=_('LLDP Neighbors'), + permission='dcim.napalm_read_device', + weight=3100 + ) + + def get_extra_context(self, request, instance): + interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( + '_path' + ).exclude( + type__in=NONCONNECTABLE_IFACE_TYPES + ) + + return { + 'interfaces': interfaces, + } + + +@register_model_view(Device, 'config') +class DeviceConfigView(generic.ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() + template_name = 'dcim/device/config.html' + tab = NAPALMViewTab( + label=_('Config'), + permission='dcim.napalm_read_device', + weight=3200 + ) + + +# +# Modules # class ModuleListView(generic.ObjectListView): @@ -1807,22 +2166,25 @@ class ModuleListView(generic.ObjectListView): table = tables.ModuleTable +@register_model_view(Module) class ModuleView(generic.ObjectView): queryset = Module.objects.all() +@register_model_view(Module, 'edit') class ModuleEditView(generic.ObjectEditView): queryset = Module.objects.all() form = forms.ModuleForm +@register_model_view(Module, 'delete') class ModuleDeleteView(generic.ObjectDeleteView): queryset = Module.objects.all() class ModuleBulkImportView(generic.BulkImportView): queryset = Module.objects.all() - model_form = forms.ModuleCSVForm + model_form = forms.ModuleImportForm table = tables.ModuleTable @@ -1851,6 +2213,7 @@ class ConsolePortListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(ConsolePort) class ConsolePortView(generic.ObjectView): queryset = ConsolePort.objects.all() @@ -1861,18 +2224,20 @@ class ConsolePortCreateView(generic.ComponentCreateView): model_form = forms.ConsolePortForm +@register_model_view(ConsolePort, 'edit') class ConsolePortEditView(generic.ObjectEditView): queryset = ConsolePort.objects.all() form = forms.ConsolePortForm +@register_model_view(ConsolePort, 'delete') class ConsolePortDeleteView(generic.ObjectDeleteView): queryset = ConsolePort.objects.all() class ConsolePortBulkImportView(generic.BulkImportView): queryset = ConsolePort.objects.all() - model_form = forms.ConsolePortCSVForm + model_form = forms.ConsolePortImportForm table = tables.ConsolePortTable @@ -1897,6 +2262,10 @@ class ConsolePortBulkDeleteView(generic.BulkDeleteView): table = tables.ConsolePortTable +# Trace view +register_model_view(ConsolePort, 'trace', kwargs={'model': ConsolePort})(PathTraceView) + + # # Console server ports # @@ -1909,6 +2278,7 @@ class ConsoleServerPortListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(ConsoleServerPort) class ConsoleServerPortView(generic.ObjectView): queryset = ConsoleServerPort.objects.all() @@ -1919,18 +2289,20 @@ class ConsoleServerPortCreateView(generic.ComponentCreateView): model_form = forms.ConsoleServerPortForm +@register_model_view(ConsoleServerPort, 'edit') class ConsoleServerPortEditView(generic.ObjectEditView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortForm +@register_model_view(ConsoleServerPort, 'delete') class ConsoleServerPortDeleteView(generic.ObjectDeleteView): queryset = ConsoleServerPort.objects.all() class ConsoleServerPortBulkImportView(generic.BulkImportView): queryset = ConsoleServerPort.objects.all() - model_form = forms.ConsoleServerPortCSVForm + model_form = forms.ConsoleServerPortImportForm table = tables.ConsoleServerPortTable @@ -1955,6 +2327,10 @@ class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView): table = tables.ConsoleServerPortTable +# Trace view +register_model_view(ConsoleServerPort, 'trace', kwargs={'model': ConsoleServerPort})(PathTraceView) + + # # Power ports # @@ -1967,6 +2343,7 @@ class PowerPortListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(PowerPort) class PowerPortView(generic.ObjectView): queryset = PowerPort.objects.all() @@ -1977,18 +2354,20 @@ class PowerPortCreateView(generic.ComponentCreateView): model_form = forms.PowerPortForm +@register_model_view(PowerPort, 'edit') class PowerPortEditView(generic.ObjectEditView): queryset = PowerPort.objects.all() form = forms.PowerPortForm +@register_model_view(PowerPort, 'delete') class PowerPortDeleteView(generic.ObjectDeleteView): queryset = PowerPort.objects.all() class PowerPortBulkImportView(generic.BulkImportView): queryset = PowerPort.objects.all() - model_form = forms.PowerPortCSVForm + model_form = forms.PowerPortImportForm table = tables.PowerPortTable @@ -2013,6 +2392,10 @@ class PowerPortBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPortTable +# Trace view +register_model_view(PowerPort, 'trace', kwargs={'model': PowerPort})(PathTraceView) + + # # Power outlets # @@ -2025,6 +2408,7 @@ class PowerOutletListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(PowerOutlet) class PowerOutletView(generic.ObjectView): queryset = PowerOutlet.objects.all() @@ -2035,18 +2419,20 @@ class PowerOutletCreateView(generic.ComponentCreateView): model_form = forms.PowerOutletForm +@register_model_view(PowerOutlet, 'edit') class PowerOutletEditView(generic.ObjectEditView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletForm +@register_model_view(PowerOutlet, 'delete') class PowerOutletDeleteView(generic.ObjectDeleteView): queryset = PowerOutlet.objects.all() class PowerOutletBulkImportView(generic.BulkImportView): queryset = PowerOutlet.objects.all() - model_form = forms.PowerOutletCSVForm + model_form = forms.PowerOutletImportForm table = tables.PowerOutletTable @@ -2071,6 +2457,10 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView): table = tables.PowerOutletTable +# Trace view +register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView) + + # # Interfaces # @@ -2083,10 +2473,19 @@ class InterfaceListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(Interface) class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() def get_extra_context(self, request, instance): + # Get assigned VDC's + vdc_table = tables.VirtualDeviceContextTable( + data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), + exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', + 'created', 'last_updated', 'actions', ), + orderable=False + ) + # Get assigned IP addresses ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), @@ -2124,6 +2523,7 @@ class InterfaceView(generic.ObjectView): ) return { + 'vdc_table': vdc_table, 'ipaddress_table': ipaddress_table, 'bridge_interfaces_table': bridge_interfaces_tables, 'child_interfaces_table': child_interfaces_tables, @@ -2137,18 +2537,20 @@ class InterfaceCreateView(generic.ComponentCreateView): model_form = forms.InterfaceForm +@register_model_view(Interface, 'edit') class InterfaceEditView(generic.ObjectEditView): queryset = Interface.objects.all() form = forms.InterfaceForm +@register_model_view(Interface, 'delete') class InterfaceDeleteView(generic.ObjectDeleteView): queryset = Interface.objects.all() class InterfaceBulkImportView(generic.BulkImportView): queryset = Interface.objects.all() - model_form = forms.InterfaceCSVForm + model_form = forms.InterfaceImportForm table = tables.InterfaceTable @@ -2173,6 +2575,10 @@ class InterfaceBulkDeleteView(generic.BulkDeleteView): table = tables.InterfaceTable +# Trace view +register_model_view(Interface, 'trace', kwargs={'model': Interface})(PathTraceView) + + # # Front ports # @@ -2185,6 +2591,7 @@ class FrontPortListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(FrontPort) class FrontPortView(generic.ObjectView): queryset = FrontPort.objects.all() @@ -2195,18 +2602,20 @@ class FrontPortCreateView(generic.ComponentCreateView): model_form = forms.FrontPortForm +@register_model_view(FrontPort, 'edit') class FrontPortEditView(generic.ObjectEditView): queryset = FrontPort.objects.all() form = forms.FrontPortForm +@register_model_view(FrontPort, 'delete') class FrontPortDeleteView(generic.ObjectDeleteView): queryset = FrontPort.objects.all() class FrontPortBulkImportView(generic.BulkImportView): queryset = FrontPort.objects.all() - model_form = forms.FrontPortCSVForm + model_form = forms.FrontPortImportForm table = tables.FrontPortTable @@ -2231,6 +2640,10 @@ class FrontPortBulkDeleteView(generic.BulkDeleteView): table = tables.FrontPortTable +# Trace view +register_model_view(FrontPort, 'trace', kwargs={'model': FrontPort})(PathTraceView) + + # # Rear ports # @@ -2243,6 +2656,7 @@ class RearPortListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(RearPort) class RearPortView(generic.ObjectView): queryset = RearPort.objects.all() @@ -2253,18 +2667,20 @@ class RearPortCreateView(generic.ComponentCreateView): model_form = forms.RearPortForm +@register_model_view(RearPort, 'edit') class RearPortEditView(generic.ObjectEditView): queryset = RearPort.objects.all() form = forms.RearPortForm +@register_model_view(RearPort, 'delete') class RearPortDeleteView(generic.ObjectDeleteView): queryset = RearPort.objects.all() class RearPortBulkImportView(generic.BulkImportView): queryset = RearPort.objects.all() - model_form = forms.RearPortCSVForm + model_form = forms.RearPortImportForm table = tables.RearPortTable @@ -2289,6 +2705,10 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTable +# Trace view +register_model_view(RearPort, 'trace', kwargs={'model': RearPort})(PathTraceView) + + # # Module bays # @@ -2301,6 +2721,7 @@ class ModuleBayListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(ModuleBay) class ModuleBayView(generic.ObjectView): queryset = ModuleBay.objects.all() @@ -2311,18 +2732,20 @@ class ModuleBayCreateView(generic.ComponentCreateView): model_form = forms.ModuleBayForm +@register_model_view(ModuleBay, 'edit') class ModuleBayEditView(generic.ObjectEditView): queryset = ModuleBay.objects.all() form = forms.ModuleBayForm +@register_model_view(ModuleBay, 'delete') class ModuleBayDeleteView(generic.ObjectDeleteView): queryset = ModuleBay.objects.all() class ModuleBayBulkImportView(generic.BulkImportView): queryset = ModuleBay.objects.all() - model_form = forms.ModuleBayCSVForm + model_form = forms.ModuleBayImportForm table = tables.ModuleBayTable @@ -2355,6 +2778,7 @@ class DeviceBayListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(DeviceBay) class DeviceBayView(generic.ObjectView): queryset = DeviceBay.objects.all() @@ -2365,15 +2789,18 @@ class DeviceBayCreateView(generic.ComponentCreateView): model_form = forms.DeviceBayForm +@register_model_view(DeviceBay, 'edit') class DeviceBayEditView(generic.ObjectEditView): queryset = DeviceBay.objects.all() form = forms.DeviceBayForm +@register_model_view(DeviceBay, 'delete') class DeviceBayDeleteView(generic.ObjectDeleteView): queryset = DeviceBay.objects.all() +@register_model_view(DeviceBay, 'populate') class DeviceBayPopulateView(generic.ObjectEditView): queryset = DeviceBay.objects.all() @@ -2407,6 +2834,7 @@ class DeviceBayPopulateView(generic.ObjectEditView): }) +@register_model_view(DeviceBay, 'depopulate') class DeviceBayDepopulateView(generic.ObjectEditView): queryset = DeviceBay.objects.all() @@ -2443,7 +2871,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView): class DeviceBayBulkImportView(generic.BulkImportView): queryset = DeviceBay.objects.all() - model_form = forms.DeviceBayCSVForm + model_form = forms.DeviceBayImportForm table = tables.DeviceBayTable @@ -2476,10 +2904,12 @@ class InventoryItemListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(InventoryItem) class InventoryItemView(generic.ObjectView): queryset = InventoryItem.objects.all() +@register_model_view(InventoryItem, 'edit') class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() form = forms.InventoryItemForm @@ -2502,13 +2932,14 @@ class InventoryItemCreateView(generic.ComponentCreateView): return instance +@register_model_view(InventoryItem, 'delete') class InventoryItemDeleteView(generic.ObjectDeleteView): queryset = InventoryItem.objects.all() class InventoryItemBulkImportView(generic.BulkImportView): queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemCSVForm + model_form = forms.InventoryItemImportForm table = tables.InventoryItemTable @@ -2542,6 +2973,7 @@ class InventoryItemRoleListView(generic.ObjectListView): table = tables.InventoryItemRoleTable +@register_model_view(InventoryItemRole) class InventoryItemRoleView(generic.ObjectView): queryset = InventoryItemRole.objects.all() @@ -2551,18 +2983,20 @@ class InventoryItemRoleView(generic.ObjectView): } +@register_model_view(InventoryItemRole, 'edit') class InventoryItemRoleEditView(generic.ObjectEditView): queryset = InventoryItemRole.objects.all() form = forms.InventoryItemRoleForm +@register_model_view(InventoryItemRole, 'delete') class InventoryItemRoleDeleteView(generic.ObjectDeleteView): queryset = InventoryItemRole.objects.all() class InventoryItemRoleBulkImportView(generic.BulkImportView): queryset = InventoryItemRole.objects.all() - model_form = forms.InventoryItemRoleCSVForm + model_form = forms.InventoryItemRoleImportForm table = tables.InventoryItemRoleTable @@ -2711,65 +3145,12 @@ class CableListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(Cable) class CableView(generic.ObjectView): queryset = Cable.objects.all() -class PathTraceView(generic.ObjectView): - """ - Trace a cable path beginning from the given path endpoint (origin). - """ - additional_permissions = ['dcim.view_cable'] - template_name = 'dcim/cable_trace.html' - - def dispatch(self, request, *args, **kwargs): - model = kwargs.pop('model') - self.queryset = model.objects.all() - - return super().dispatch(request, *args, **kwargs) - - def get_extra_context(self, request, instance): - related_paths = [] - - # If tracing a PathEndpoint, locate the CablePath (if one exists) by its origin - if isinstance(instance, PathEndpoint): - path = instance._path - - # Otherwise, find all CablePaths which traverse the specified object - else: - related_paths = CablePath.objects.filter(_nodes__contains=instance) - # Check for specification of a particular path (when tracing pass-through ports) - try: - path_id = int(request.GET.get('cablepath_id')) - except TypeError: - path_id = None - if path_id in list(related_paths.values_list('pk', flat=True)): - path = CablePath.objects.get(pk=path_id) - else: - path = related_paths.first() - - # No paths found - if path is None: - return { - 'path': None - } - - # Get the total length of the cable and whether the length is definitive (fully defined) - total_length, is_definitive = path.get_total_length() if path else (None, False) - - # Determine the path to the SVG trace image - api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" - svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" - - return { - 'path': path, - 'related_paths': related_paths, - 'total_length': total_length, - 'is_definitive': is_definitive, - 'svg_url': svg_url, - } - - +@register_model_view(Cable, 'edit') class CableEditView(generic.ObjectEditView): queryset = Cable.objects.all() template_name = 'dcim/cable_edit.html' @@ -2803,13 +3184,14 @@ class CableEditView(generic.ObjectEditView): return obj +@register_model_view(Cable, 'delete') class CableDeleteView(generic.ObjectDeleteView): queryset = Cable.objects.all() class CableBulkImportView(generic.BulkImportView): queryset = Cable.objects.all() - model_form = forms.CableCSVForm + model_form = forms.CableImportForm table = tables.CableTable @@ -2891,6 +3273,7 @@ class VirtualChassisListView(generic.ObjectListView): filterset_form = forms.VirtualChassisFilterForm +@register_model_view(VirtualChassis) class VirtualChassisView(generic.ObjectView): queryset = VirtualChassis.objects.all() @@ -2908,6 +3291,7 @@ class VirtualChassisCreateView(generic.ObjectEditView): template_name = 'dcim/virtualchassis_add.html' +@register_model_view(VirtualChassis, 'edit') class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): queryset = VirtualChassis.objects.all() @@ -2976,10 +3360,12 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V }) +@register_model_view(VirtualChassis, 'delete') class VirtualChassisDeleteView(generic.ObjectDeleteView): queryset = VirtualChassis.objects.all() +@register_model_view(VirtualChassis, 'add_member', path='add-member') class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): queryset = VirtualChassis.objects.all() @@ -3088,7 +3474,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL class VirtualChassisBulkImportView(generic.BulkImportView): queryset = VirtualChassis.objects.all() - model_form = forms.VirtualChassisCSVForm + model_form = forms.VirtualChassisImportForm table = tables.VirtualChassisTable @@ -3118,6 +3504,7 @@ class PowerPanelListView(generic.ObjectListView): table = tables.PowerPanelTable +@register_model_view(PowerPanel) class PowerPanelView(generic.ObjectView): queryset = PowerPanel.objects.all() @@ -3136,18 +3523,20 @@ class PowerPanelView(generic.ObjectView): } +@register_model_view(PowerPanel, 'edit') class PowerPanelEditView(generic.ObjectEditView): queryset = PowerPanel.objects.all() form = forms.PowerPanelForm +@register_model_view(PowerPanel, 'delete') class PowerPanelDeleteView(generic.ObjectDeleteView): queryset = PowerPanel.objects.all() class PowerPanelBulkImportView(generic.BulkImportView): queryset = PowerPanel.objects.all() - model_form = forms.PowerPanelCSVForm + model_form = forms.PowerPanelImportForm table = tables.PowerPanelTable @@ -3177,22 +3566,25 @@ class PowerFeedListView(generic.ObjectListView): table = tables.PowerFeedTable +@register_model_view(PowerFeed) class PowerFeedView(generic.ObjectView): queryset = PowerFeed.objects.all() +@register_model_view(PowerFeed, 'edit') class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() form = forms.PowerFeedForm +@register_model_view(PowerFeed, 'delete') class PowerFeedDeleteView(generic.ObjectDeleteView): queryset = PowerFeed.objects.all() class PowerFeedBulkImportView(generic.BulkImportView): queryset = PowerFeed.objects.all() - model_form = forms.PowerFeedCSVForm + model_form = forms.PowerFeedImportForm table = tables.PowerFeedTable @@ -3211,3 +3603,62 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView): queryset = PowerFeed.objects.all() filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable + + +# Trace view +register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView) + + +# VDC +class VirtualDeviceContextListView(generic.ObjectListView): + queryset = VirtualDeviceContext.objects.annotate( + interface_count=count_related(Interface, 'vdcs'), + ) + filterset = filtersets.VirtualDeviceContextFilterSet + filterset_form = forms.VirtualDeviceContextFilterForm + table = tables.VirtualDeviceContextTable + + +@register_model_view(VirtualDeviceContext) +class VirtualDeviceContextView(generic.ObjectView): + queryset = VirtualDeviceContext.objects.all() + + def get_extra_context(self, request, instance): + interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user) + interfaces_table.configure(request) + interfaces_table.columns.hide('device') + + return { + 'interfaces_table': interfaces_table, + 'interface_count': instance.interfaces.count(), + } + + +@register_model_view(VirtualDeviceContext, 'edit') +class VirtualDeviceContextEditView(generic.ObjectEditView): + queryset = VirtualDeviceContext.objects.all() + form = forms.VirtualDeviceContextForm + + +@register_model_view(VirtualDeviceContext, 'delete') +class VirtualDeviceContextDeleteView(generic.ObjectDeleteView): + queryset = VirtualDeviceContext.objects.all() + + +class VirtualDeviceContextBulkImportView(generic.BulkImportView): + queryset = VirtualDeviceContext.objects.all() + model_form = forms.VirtualDeviceContextImportForm + table = tables.VirtualDeviceContextTable + + +class VirtualDeviceContextBulkEditView(generic.BulkEditView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable + form = forms.VirtualDeviceContextBulkEditForm + + +class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 01011b276..837a8f2d3 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin): }) return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context) - - -# -# Reports & scripts -# - -@admin.register(JobResult) -class JobResultAdmin(admin.ModelAdmin): - list_display = [ - 'obj_type', 'name', 'created', 'completed', 'user', 'status', - ] - fields = [ - 'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id' - ] - list_filter = [ - 'status', - ] - readonly_fields = fields - - def has_add_permission(self, request): - return False diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 44dfe7cbc..5644b0b4e 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -13,6 +13,7 @@ __all__ = [ 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedJournalEntrySerializer', + 'NestedSavedFilterSerializer', 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedSavedFilterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + + class Meta: + model = models.SavedFilter + fields = ['id', 'url', 'display', 'name', 'slug'] + + class NestedImageAttachmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 764c7750a..8b9c6dcb1 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -38,6 +38,8 @@ __all__ = ( 'ObjectChangeSerializer', 'ReportDetailSerializer', 'ReportSerializer', + 'ReportInputSerializer', + 'SavedFilterSerializer', 'ScriptDetailSerializer', 'ScriptInputSerializer', 'ScriptLogMessageSerializer', @@ -91,14 +93,16 @@ class CustomFieldSerializer(ValidatedModelSerializer): model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', - 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', + 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight', + 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): types = CustomFieldTypeChoices if obj.type == types.TYPE_INTEGER: return 'integer' + if obj.type == types.TYPE_DECIMAL: + return 'decimal' if obj.type == types.TYPE_BOOLEAN: return 'boolean' if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): @@ -114,14 +118,15 @@ class CustomFieldSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') - content_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) + content_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + many=True ) class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ] @@ -132,18 +137,38 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_type = ContentTypeField( + content_types = ContentTypeField( queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + many=True ) class Meta: model = ExportTemplate fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ] +# +# Saved filters +# + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', + 'shared', 'parameters', 'created', 'last_updated', + ] + + # # Tags # @@ -360,7 +385,8 @@ class JobResultSerializer(BaseModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + 'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name', + 'obj_type', 'user', 'data', 'job_id', ] @@ -386,6 +412,11 @@ class ReportDetailSerializer(ReportSerializer): result = JobResultSerializer() +class ReportInputSerializer(serializers.Serializer): + schedule_at = serializers.DateTimeField(required=False, allow_null=True) + interval = serializers.IntegerField(required=False, allow_null=True) + + # # Scripts # @@ -417,6 +448,8 @@ class ScriptDetailSerializer(ScriptSerializer): class ScriptInputSerializer(serializers.Serializer): data = serializers.JSONField() commit = serializers.BooleanField() + schedule_at = serializers.DateTimeField(required=False, allow_null=True) + interval = serializers.IntegerField(required=False, allow_null=True) class ScriptLogMessageSerializer(serializers.Serializer): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bcad6b77c..91067d40d 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,43 +5,19 @@ from . import views router = NetBoxRouter() router.APIRootView = views.ExtrasRootView -# Webhooks router.register('webhooks', views.WebhookViewSet) - -# Custom fields router.register('custom-fields', views.CustomFieldViewSet) - -# Custom links router.register('custom-links', views.CustomLinkViewSet) - -# Export templates router.register('export-templates', views.ExportTemplateViewSet) - -# Tags +router.register('saved-filters', views.SavedFilterViewSet) router.register('tags', views.TagViewSet) - -# Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) - -# Journal entries router.register('journal-entries', views.JournalEntryViewSet) - -# Config contexts router.register('config-contexts', views.ConfigContextViewSet) - -# Reports router.register('reports', views.ReportViewSet, basename='report') - -# Scripts router.register('scripts', views.ScriptViewSet, basename='script') - -# Change logging router.register('object-changes', views.ObjectChangeViewSet) - -# Job Results router.register('job-results', views.JobResultViewSet) - -# ContentTypes router.register('content-types', views.ContentTypeViewSet) app_name = 'extras-api' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 63003bdf2..56bc8567d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -98,6 +98,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet): filterset_class = filtersets.ExportTemplateFilterSet +# +# Saved filters +# + +class SavedFilterViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = SavedFilter.objects.all() + serializer_class = serializers.SavedFilterSerializer + filterset_class = filtersets.SavedFilterFilterSet + + # # Tags # @@ -231,19 +242,24 @@ class ReportViewSet(ViewSet): # Retrieve and run the Report. This will create a new JobResult. report = self._retrieve_report(pk) - report_content_type = ContentType.objects.get(app_label='extras', model='report') - job_result = JobResult.enqueue_job( - run_report, - report.full_name, - report_content_type, - request.user, - job_timeout=report.job_timeout - ) - report.result = job_result + input_serializer = serializers.ReportInputSerializer(data=request.data) - serializer = serializers.ReportDetailSerializer(report, context={'request': request}) + if input_serializer.is_valid(): + job_result = JobResult.enqueue_job( + run_report, + name=report.full_name, + obj_type=ContentType.objects.get_for_model(Report), + user=request.user, + job_timeout=report.job_timeout, + schedule_at=input_serializer.validated_data.get('schedule_at'), + interval=input_serializer.validated_data.get('interval') + ) + report.result = job_result - return Response(serializer.data) + serializer = serializers.ReportDetailSerializer(report, context={'request': request}) + + return Response(serializer.data) + return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) # @@ -310,19 +326,17 @@ class ScriptViewSet(ViewSet): raise RQWorkerNotRunningException() if input_serializer.is_valid(): - data = input_serializer.data['data'] - commit = input_serializer.data['commit'] - - script_content_type = ContentType.objects.get(app_label='extras', model='script') job_result = JobResult.enqueue_job( run_script, - script.full_name, - script_content_type, - request.user, - data=data, + name=script.full_name, + obj_type=ContentType.objects.get_for_model(Script), + user=request.user, + data=input_serializer.data['data'], request=copy_safe_request(request), - commit=commit, + commit=input_serializer.data['commit'], job_timeout=script.job_timeout, + schedule_at=input_serializer.validated_data.get('schedule_at'), + interval=input_serializer.validated_data.get('interval') ) script.result = job_result serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 7500157c0..965eb033e 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -5,5 +5,4 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.lookups - import extras.signals + from . import lookups, search, signals diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 123fd2cd4..92d09e2ad 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_TEXT = 'text' TYPE_LONGTEXT = 'longtext' TYPE_INTEGER = 'integer' + TYPE_DECIMAL = 'decimal' TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' TYPE_URL = 'url' @@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_TEXT, 'Text'), (TYPE_LONGTEXT, 'Text (long)'), (TYPE_INTEGER, 'Integer'), + (TYPE_DECIMAL, 'Decimal'), (TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), @@ -139,17 +141,19 @@ class LogLevelChoices(ChoiceSet): class JobResultStatusChoices(ChoiceSet): STATUS_PENDING = 'pending' + STATUS_SCHEDULED = 'scheduled' STATUS_RUNNING = 'running' STATUS_COMPLETED = 'completed' STATUS_ERRORED = 'errored' STATUS_FAILED = 'failed' CHOICES = ( - (STATUS_PENDING, 'Pending'), - (STATUS_RUNNING, 'Running'), - (STATUS_COMPLETED, 'Completed'), - (STATUS_ERRORED, 'Errored'), - (STATUS_FAILED, 'Failed'), + (STATUS_PENDING, 'Pending', 'cyan'), + (STATUS_SCHEDULED, 'Scheduled', 'gray'), + (STATUS_RUNNING, 'Running', 'blue'), + (STATUS_COMPLETED, 'Completed', 'green'), + (STATUS_ERRORED, 'Errored', 'red'), + (STATUS_FAILED, 'Failed', 'red'), ) TERMINAL_STATE_CHOICES = ( @@ -178,3 +182,20 @@ class WebhookHttpMethodChoices(ChoiceSet): (METHOD_PATCH, 'PATCH'), (METHOD_DELETE, 'DELETE'), ) + + +# +# Staging +# + +class ChangeActionChoices(ChoiceSet): + + ACTION_CREATE = 'create' + ACTION_UPDATE = 'update' + ACTION_DELETE = 'delete' + + CHOICES = ( + (ACTION_CREATE, 'Create'), + (ACTION_UPDATE, 'Update'), + (ACTION_DELETE, 'Delete'), + ) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index df0af3541..74b98ccf6 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -2,6 +2,7 @@ import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet @@ -19,9 +20,11 @@ __all__ = ( 'CustomLinkFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', + 'JobResultFilterSet', 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'SavedFilterFilterSet', 'TagFilterSet', 'WebhookFilterSet', ) @@ -30,7 +33,7 @@ __all__ = ( class WebhookFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type_id = MultiValueNumberFilter( field_name='content_types__id' @@ -59,7 +62,7 @@ class WebhookFilterSet(BaseFilterSet): class CustomFieldFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) type = django_filters.MultipleChoiceFilter( choices=CustomFieldTypeChoices @@ -72,8 +75,8 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', - 'description', + 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility', + 'weight', 'description', ] def search(self, queryset, name, value): @@ -90,13 +93,17 @@ class CustomFieldFilterSet(BaseFilterSet): class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = CustomLink fields = [ - 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + 'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', ] def search(self, queryset, name, value): @@ -113,12 +120,16 @@ class CustomLinkFilterSet(BaseFilterSet): class ExportTemplateFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'description'] + fields = ['id', 'content_types', 'name', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -129,10 +140,59 @@ class ExportTemplateFilterSet(BaseFilterSet): ) +class SavedFilterFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=User.objects.all(), + to_field_name='username', + label=_('User (name)'), + ) + usable = django_filters.BooleanFilter( + method='_usable' + ) + + class Meta: + model = SavedFilter + fields = ['id', 'content_types', 'name', 'slug', 'description', 'enabled', 'shared', 'weight'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + def _usable(self, queryset, name, value): + """ + Return only SavedFilters that are both enabled and are shared (or belong to the current user). + """ + user = self.request.user if self.request else None + if not user or user.is_anonymous: + if value: + return queryset.filter(enabled=True, shared=True) + return queryset.filter(Q(enabled=False) | Q(shared=False)) + if value: + return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user)) + return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) + + class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) created = django_filters.DateTimeFilter() content_type = ContentTypeFilter() @@ -152,13 +212,13 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): assigned_object_type = ContentTypeFilter() created_by_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) kind = django_filters.MultipleChoiceFilter( choices=JournalEntryKindChoices @@ -177,7 +237,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): class TagFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type = MultiValueCharFilter( method='_content_type' @@ -229,138 +289,138 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) region_id = django_filters.ModelMultipleChoiceFilter( field_name='regions', queryset=Region.objects.all(), - label='Region', + label=_('Region'), ) region = django_filters.ModelMultipleChoiceFilter( field_name='regions__slug', queryset=Region.objects.all(), to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group = django_filters.ModelMultipleChoiceFilter( field_name='site_groups__slug', queryset=SiteGroup.objects.all(), to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_group_id = django_filters.ModelMultipleChoiceFilter( field_name='site_groups', queryset=SiteGroup.objects.all(), - label='Site group', + label=_('Site group'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), - label='Site', + label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( field_name='locations', queryset=Location.objects.all(), - label='Location', + label=_('Location'), ) location = django_filters.ModelMultipleChoiceFilter( field_name='locations__slug', queryset=Location.objects.all(), to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), - label='Device type', + label=_('Device type'), ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), - label='Role', + label=_('Role'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) platform_id = django_filters.ModelMultipleChoiceFilter( field_name='platforms', queryset=Platform.objects.all(), - label='Platform', + label=_('Platform'), ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platforms__slug', queryset=Platform.objects.all(), to_field_name='slug', - label='Platform (slug)', + label=_('Platform (slug)'), ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_types', queryset=ClusterType.objects.all(), - label='Cluster type', + label=_('Cluster type'), ) cluster_type = django_filters.ModelMultipleChoiceFilter( field_name='cluster_types__slug', queryset=ClusterType.objects.all(), to_field_name='slug', - label='Cluster type (slug)', + label=_('Cluster type (slug)'), ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups', queryset=ClusterGroup.objects.all(), - label='Cluster group', + label=_('Cluster group'), ) cluster_group = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', - label='Cluster group (slug)', + label=_('Cluster group (slug)'), ) cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='clusters', queryset=Cluster.objects.all(), - label='Cluster', + label=_('Cluster'), ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups', queryset=TenantGroup.objects.all(), - label='Tenant group', + label=_('Tenant group'), ) tenant_group = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='tenants', queryset=Tenant.objects.all(), - label='Tenant', + label=_('Tenant'), ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='tenants__slug', queryset=Tenant.objects.all(), to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) tag_id = django_filters.ModelMultipleChoiceFilter( field_name='tags', queryset=Tag.objects.all(), - label='Tag', + label=_('Tag'), ) tag = django_filters.ModelMultipleChoiceFilter( field_name='tags__slug', queryset=Tag.objects.all(), to_field_name='slug', - label='Tag (slug)', + label=_('Tag (slug)'), ) class Meta: @@ -384,7 +444,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): class LocalConfigContextFilterSet(django_filters.FilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', - label='Has local config context data', + label=_('Has local config context data'), ) def _local_context_data(self, queryset, name, value): @@ -394,19 +454,19 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) time = django_filters.DateTimeFromToRangeFilter() changed_object_type = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User name', + label=_('User name'), ) class Meta: @@ -432,10 +492,44 @@ class ObjectChangeFilterSet(BaseFilterSet): class JobResultFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) created = django_filters.DateTimeFilter() + created__before = django_filters.DateTimeFilter( + field_name='created', + lookup_expr='lte' + ) + created__after = django_filters.DateTimeFilter( + field_name='created', + lookup_expr='gte' + ) + scheduled = django_filters.DateTimeFilter() + scheduled__before = django_filters.DateTimeFilter( + field_name='scheduled', + lookup_expr='lte' + ) + scheduled__after = django_filters.DateTimeFilter( + field_name='scheduled', + lookup_expr='gte' + ) + started = django_filters.DateTimeFilter() + started__before = django_filters.DateTimeFilter( + field_name='started', + lookup_expr='lte' + ) + started__after = django_filters.DateTimeFilter( + field_name='started', + lookup_expr='gte' + ) completed = django_filters.DateTimeFilter() + completed__before = django_filters.DateTimeFilter( + field_name='completed', + lookup_expr='lte' + ) + completed__after = django_filters.DateTimeFilter( + field_name='completed', + lookup_expr='gte' + ) status = django_filters.MultipleChoiceFilter( choices=JobResultStatusChoices, null_value=None @@ -443,15 +537,14 @@ class JobResultFilterSet(BaseFilterSet): class Meta: model = JobResult - fields = [ - 'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name' - ] + fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name') def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(user__username__icontains=value) + Q(user__username__icontains=value) | + Q(name__icontains=value) ) @@ -462,7 +555,7 @@ class JobResultFilterSet(BaseFilterSet): class ContentTypeFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) class Meta: diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index b470650da..af0f7cf43 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -1,7 +1,7 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * -from .customfields import * +from .mixins import * from .config import * from .scripts import * diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b1d8a6c21..6e245bcaf 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,11 +1,10 @@ from django import forms -from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from utilities.forms import ( - add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, + add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect, ) __all__ = ( @@ -14,6 +13,7 @@ __all__ = ( 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -38,7 +38,7 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False ) ui_visibility = forms.ChoiceField( - label="UI visibility", + label=_("UI visibility"), choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, initial='', @@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm): queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), - required=False - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -81,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm): queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), - required=False - ) description = forms.CharField( max_length=200, required=False @@ -106,6 +96,30 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_extension') +class SavedFilterBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + weight = forms.IntegerField( + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + shared = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + nullable_fields = ('description',) + + class WebhookBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Webhook.objects.all(), @@ -130,23 +144,23 @@ class WebhookBulkEditForm(BulkEditForm): http_method = forms.ChoiceField( choices=add_blank_choice(WebhookHttpMethodChoices), required=False, - label='HTTP method' + label=_('HTTP method') ) payload_url = forms.CharField( required=False, - label='Payload URL' + label=_('Payload URL') ) ssl_verification = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='SSL verification' + label=_('SSL verification') ) secret = forms.CharField( required=False ) ca_file_path = forms.CharField( required=False, - label='CA file path' + label=_('CA file path') ) nullable_fields = ('secret', 'conditions', 'ca_file_path') diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index e83cac3b9..cf723c4f7 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices from extras.models import * @@ -9,83 +10,97 @@ from extras.utils import FeatureQuery from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField __all__ = ( - 'CustomFieldCSVForm', - 'CustomLinkCSVForm', - 'ExportTemplateCSVForm', - 'TagCSVForm', - 'WebhookCSVForm', + 'CustomFieldImportForm', + 'CustomLinkImportForm', + 'ExportTemplateImportForm', + 'SavedFilterImportForm', + 'TagImportForm', + 'WebhookImportForm', ) -class CustomFieldCSVForm(CSVModelForm): +class CustomFieldImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) type = CSVChoiceField( choices=CustomFieldTypeChoices, - help_text='Field data type (e.g. text, integer, etc.)' + help_text=_('Field data type (e.g. text, integer, etc.)') ) object_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False, - help_text="Object type (for object or multi-object fields)" + help_text=_("Object type (for object or multi-object fields)") ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, - help_text='Comma-separated list of field choices' + help_text=_('Comma-separated list of field choices') ) ui_visibility = CSVChoiceField( choices=CustomFieldVisibilityChoices, - help_text='How the custom field is displayed in the user interface' + help_text=_('How the custom field is displayed in the user interface') ) class Meta: model = CustomField fields = ( - 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', - 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', + 'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visibility', ) -class CustomLinkCSVForm(CSVModelForm): - content_type = CSVContentTypeField( +class CustomLinkImportForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), - help_text="Assigned object type" + help_text=_("One or more assigned object types") ) class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', ) -class ExportTemplateCSVForm(CSVModelForm): - content_type = CSVContentTypeField( +class ExportTemplateImportForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), - help_text="Assigned object type" + help_text=_("One or more assigned object types") ) class Meta: model = ExportTemplate fields = ( - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', ) -class WebhookCSVForm(CSVModelForm): +class SavedFilterImportForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + help_text=_("One or more assigned object types") + ) + + class Meta: + model = SavedFilter + fields = ( + 'name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + ) + + +class WebhookImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) class Meta: @@ -97,12 +112,12 @@ class WebhookCSVForm(CSVModelForm): ) -class TagCSVForm(CSVModelForm): +class TagImportForm(CSVModelForm): slug = SlugField() class Meta: model = Tag fields = ('name', 'slug', 'color', 'description') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 526d47013..e6a9089bc 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -15,30 +15,33 @@ from utilities.forms import ( StaticSelect, TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType +from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', 'CustomFieldFilterForm', + 'JobResultFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', + 'SavedFilterFilterForm', 'TagFilterForm', 'WebhookFilterForm', ) -class CustomFieldFilterForm(FilterForm): +class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter_id')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False, - label='Object type' + label=_('Object type') ) type = MultipleChoiceField( choices=CustomFieldTypeChoices, @@ -65,12 +68,73 @@ class CustomFieldFilterForm(FilterForm): ) -class CustomLinkFilterForm(FilterForm): +class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), - ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), + (None, ('q', 'filter_id')), + ('Attributes', ('obj_type', 'status')), + ('Creation', ( + 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', + 'started__after', 'completed__before', 'completed__after', 'user', + )), ) - content_type = ContentTypeChoiceField( + obj_type = ContentTypeChoiceField( + label=_('Object Type'), + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work + required=False, + ) + status = MultipleChoiceField( + choices=JobResultStatusChoices, + required=False + ) + created__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + created__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + scheduled__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + scheduled__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + started__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + started__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + completed__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + completed__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + user = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ) + ) + + +class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), + ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), + ) + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), required=False @@ -92,12 +156,12 @@ class CustomLinkFilterForm(FilterForm): ) -class ExportTemplateFilterForm(FilterForm): +class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), - ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), + (None, ('q', 'filter_id')), + ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), required=False @@ -117,9 +181,36 @@ class ExportTemplateFilterForm(FilterForm): ) -class WebhookFilterForm(FilterForm): +class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter_id')), + ('Attributes', ('content_types', 'enabled', 'shared', 'weight')), + ) + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('export_templates'), + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + shared = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + weight = forms.IntegerField( + required=False + ) + + +class WebhookFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) @@ -127,7 +218,7 @@ class WebhookFilterForm(FilterForm): queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), required=False, - label='Object type' + label=_('Object type') ) http_method = MultipleChoiceField( choices=WebhookHttpMethodChoices, @@ -160,7 +251,7 @@ class WebhookFilterForm(FilterForm): ) -class TagFilterForm(FilterForm): +class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), @@ -169,9 +260,9 @@ class TagFilterForm(FilterForm): ) -class ConfigContextFilterForm(FilterForm): +class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'tag_id')), + (None, ('q', 'filter_id', 'tag_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), @@ -258,7 +349,7 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -296,10 +387,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ObjectChangeFilterForm(FilterForm): +class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter_id')), ('Time', ('time_before', 'time_after')), ('Attributes', ('action', 'user_id', 'changed_object_type_id')), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/mixins.py similarity index 82% rename from netbox/extras/forms/customfields.py rename to netbox/extras/forms/mixins.py index 40d068450..640bcc3dc 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/mixins.py @@ -1,10 +1,14 @@ from django.contrib.contenttypes.models import ContentType +from django import forms +from django.utils.translation import gettext as _ from extras.models import * from extras.choices import CustomFieldVisibilityChoices +from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', + 'SavedFiltersMixin', ) @@ -57,3 +61,14 @@ class CustomFieldsMixin: if customfield.group_name not in self.custom_field_groups: self.custom_field_groups[customfield.group_name] = [] self.custom_field_groups[customfield.group_name].append(field_name) + + +class SavedFiltersMixin(forms.Form): + filter_id = DynamicModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + required=False, + label=_('Saved Filter'), + query_params={ + 'usable': True, + } + ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/model_forms.py similarity index 78% rename from netbox/extras/forms/models.py rename to netbox/extras/forms/model_forms.py index bea1fbcc1..f9b145803 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/model_forms.py @@ -1,5 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict +from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -20,6 +22,7 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'SavedFilterForm', 'TagForm', 'WebhookForm', ) @@ -29,21 +32,21 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - label='Model(s)' + label=_('Model(s)') ) object_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), # TODO: Come up with a canonical way to register suitable models limit_choices_to=FeatureQuery('webhooks'), required=False, - help_text="Type of the related object (for object/multi-object fields only)" + help_text=_("Type of the related object (for object/multi-object fields only)") ) fieldsets = ( ('Custom Field', ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), - ('Behavior', ('filter_logic', 'ui_visibility')), + ('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -52,8 +55,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' help_texts = { - 'type': "The type of data stored in this field. For object/multi-object fields, select the related object " - "type below." + 'type': _("The type of data stored in this field. For object/multi-object fields, select the related object " + "type below.") } widgets = { 'type': StaticSelect(), @@ -63,13 +66,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links') ) fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), + ('Custom Link', ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) @@ -82,20 +85,20 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), } help_texts = { - 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ object }}. ' - 'Links which render as empty text will not be displayed.', - 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ object }}.', + 'link_text': _('Jinja2 template code for the link text. Reference the object as {{ object }}. ' + 'Links which render as empty text will not be displayed.'), + 'link_url': _('Jinja2 template code for the link URL. Reference the object as {{ object }}.'), } class ExportTemplateForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates') ) fieldsets = ( - ('Export Template', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_types', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) @@ -108,6 +111,33 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): } +class SavedFilterForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + parameters = JSONField() + + fieldsets = ( + ('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Parameters', ('parameters',)), + ) + + class Meta: + model = SavedFilter + exclude = ('user',) + + def __init__(self, *args, initial=None, **kwargs): + + # Convert any parameters delivered via initial data to a dictionary + if initial and 'parameters' in initial: + if type(initial['parameters']) is str: + # TODO: Make a utility function for this + initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + + super().__init__(*args, initial=initial, **kwargs) + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py new file mode 100644 index 000000000..338d05fa3 --- /dev/null +++ b/netbox/extras/forms/reports.py @@ -0,0 +1,39 @@ +from django import forms +from django.utils import timezone +from django.utils.translation import gettext as _ + +from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget + +__all__ = ( + 'ReportForm', +) + + +class ReportForm(BootstrapMixin, forms.Form): + schedule_at = forms.DateTimeField( + required=False, + widget=DateTimePicker(), + label=_("Schedule at"), + help_text=_("Schedule execution of report to a set time"), + ) + interval = forms.IntegerField( + required=False, + min_value=1, + label=_("Recurs every"), + widget=SelectDurationWidget(), + help_text=_("Interval at which this report is re-run (in minutes)") + ) + + def clean_schedule_at(self): + scheduled_time = self.cleaned_data['schedule_at'] + if scheduled_time and scheduled_time < timezone.now(): + raise forms.ValidationError(_('Scheduled time must be in the future.')) + + return scheduled_time + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Annotate the current system time for reference + now = timezone.now().strftime('%Y-%m-%d %H:%M:%S') + self.fields['schedule_at'].help_text += f' (current time: {now})' diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 380b4364c..58388e247 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,6 +1,8 @@ from django import forms +from django.utils import timezone +from django.utils.translation import gettext as _ -from utilities.forms import BootstrapMixin +from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget __all__ = ( 'ScriptForm', @@ -11,20 +13,50 @@ class ScriptForm(BootstrapMixin, forms.Form): _commit = forms.BooleanField( required=False, initial=True, - label="Commit changes", - help_text="Commit changes to the database (uncheck for a dry-run)" + label=_("Commit changes"), + help_text=_("Commit changes to the database (uncheck for a dry-run)") + ) + _schedule_at = forms.DateTimeField( + required=False, + widget=DateTimePicker(), + label=_("Schedule at"), + help_text=_("Schedule execution of script to a set time"), + ) + _interval = forms.IntegerField( + required=False, + min_value=1, + label=_("Recurs every"), + widget=SelectDurationWidget(), + help_text=_("Interval at which this script is re-run (in minutes)") ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Move _commit to the end of the form + # Annotate the current system time for reference + now = timezone.now().strftime('%Y-%m-%d %H:%M:%S') + self.fields['_schedule_at'].help_text += f' (current time: {now})' + + # Move _commit and _schedule_at to the end of the form + schedule_at = self.fields.pop('_schedule_at') + interval = self.fields.pop('_interval') commit = self.fields.pop('_commit') + self.fields['_schedule_at'] = schedule_at + self.fields['_interval'] = interval self.fields['_commit'] = commit + def clean__schedule_at(self): + scheduled_time = self.cleaned_data['_schedule_at'] + if scheduled_time and scheduled_time < timezone.now(): + raise forms.ValidationError({ + '_schedule_at': _('Scheduled time must be in the future.') + }) + + return scheduled_time + @property def requires_input(self): """ - A boolean indicating whether the form requires user input (ignore the _commit field). + A boolean indicating whether the form requires user input (ignore the built-in fields). """ - return bool(len(self.fields) > 1) + return bool(len(self.fields) > 3) diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 462ba721f..7045575fb 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -59,3 +59,10 @@ class TagsMixin: def resolve_tags(self, info): return self.tags.all() + + +class ContactsMixin: + contacts = graphene.List('tenancy.graphql.types.ContactAssignmentType') + + def resolve_contacts(self, info): + return list(self.contacts.all()) diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 3073976e8..0c3113879 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType): image_attachment = ObjectField(ImageAttachmentType) image_attachment_list = ObjectListField(ImageAttachmentType) + saved_filter = ObjectField(SavedFilterType) + saved_filter_list = ObjectListField(SavedFilterType) + journal_entry = ObjectField(JournalEntryType) journal_entry_list = ObjectListField(JournalEntryType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index dbaa0bee2..b5d4dffce 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -10,6 +10,7 @@ __all__ = ( 'ImageAttachmentType', 'JournalEntryType', 'ObjectChangeType', + 'SavedFilterType', 'TagType', 'WebhookType', ) @@ -27,7 +28,7 @@ class CustomFieldType(ObjectType): class Meta: model = models.CustomField - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.CustomFieldFilterSet @@ -35,7 +36,7 @@ class CustomLinkType(ObjectType): class Meta: model = models.CustomLink - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.CustomLinkFilterSet @@ -43,7 +44,7 @@ class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.ExportTemplateFilterSet @@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType): filterset_class = filtersets.ObjectChangeFilterSet +class SavedFilterType(ObjectType): + + class Meta: + model = models.SavedFilter + exclude = ('content_types', ) + filterset_class = filtersets.SavedFilterFilterSet + + class TagType(ObjectType): class Meta: @@ -83,5 +92,5 @@ class WebhookType(ObjectType): class Meta: model = models.Webhook - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.WebhookFilterSet diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index 51d50d7e1..42690568d 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -81,7 +81,7 @@ class Command(BaseCommand): ending="" ) self.stdout.flush() - JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS) + JobResult.objects.filter(created__lt=cutoff).delete() if options['verbosity']: self.stdout.write("Done.", self.style.SUCCESS) elif options['verbosity']: diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py new file mode 100644 index 000000000..f519688f8 --- /dev/null +++ b/netbox/extras/management/commands/reindex.py @@ -0,0 +1,77 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand, CommandError + +from netbox.registry import registry +from netbox.search.backends import search_backend + + +class Command(BaseCommand): + help = 'Reindex objects for search' + + def add_arguments(self, parser): + parser.add_argument( + 'args', + metavar='app_label[.ModelName]', + nargs='*', + help='One or more apps or models to reindex', + ) + + def _get_indexers(self, *model_names): + indexers = {} + + # No models specified; pull in all registered indexers + if not model_names: + for idx in registry['search'].values(): + indexers[idx.model] = idx + + # Return only indexers for the specified models + else: + for label in model_names: + try: + app_label, model_name = label.lower().split('.') + except ValueError: + raise CommandError( + f"Invalid model: {label}. Model names must be in the format .." + ) + try: + idx = registry['search'][f'{app_label}.{model_name}'] + indexers[idx.model] = idx + except KeyError: + raise CommandError(f"No indexer registered for {label}") + + return indexers + + def handle(self, *model_labels, **kwargs): + + # Determine which models to reindex + indexers = self._get_indexers(*model_labels) + if not indexers: + raise CommandError("No indexers found!") + self.stdout.write(f'Reindexing {len(indexers)} models.') + + # Clear all cached values for the specified models + self.stdout.write('Clearing cached values... ', ending='') + self.stdout.flush() + content_types = [ + ContentType.objects.get_for_model(model) for model in indexers.keys() + ] + deleted_count = search_backend.clear(content_types) + self.stdout.write(f'{deleted_count} entries deleted.') + + # Index models + self.stdout.write('Indexing models') + for model, idx in indexers.items(): + app_label = model._meta.app_label + model_name = model._meta.model_name + self.stdout.write(f' {app_label}.{model_name}... ', ending='') + self.stdout.flush() + i = search_backend.cache(model.objects.iterator(), remove_existing=False) + if i: + self.stdout.write(f'{i} entries cached.') + else: + self.stdout.write(f'None found.') + + msg = f'Completed.' + if total_count := search_backend.size: + msg += f' Total entries: {total_count}' + self.stdout.write(msg, self.style.SUCCESS) diff --git a/netbox/extras/management/commands/rqworker.py b/netbox/extras/management/commands/rqworker.py index e2ad5b15c..e1fb6fd11 100644 --- a/netbox/extras/management/commands/rqworker.py +++ b/netbox/extras/management/commands/rqworker.py @@ -14,6 +14,8 @@ class Command(_Command): of only the 'default' queue). """ def handle(self, *args, **options): + # Run the worker with scheduler functionality + options['with_scheduler'] = True # If no queues have been specified on the command line, listen on all configured queues. if len(args) < 1: diff --git a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py index 73a3e466c..5f2d7f7f3 100644 --- a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py +++ b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='journalentry', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='journalentry', diff --git a/netbox/extras/migrations/0078_unique_constraints.py b/netbox/extras/migrations/0078_unique_constraints.py new file mode 100644 index 000000000..4a56831a7 --- /dev/null +++ b/netbox/extras/migrations/0078_unique_constraints.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0077_customlink_extend_text_and_url'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='webhook', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='exporttemplate', + constraint=models.UniqueConstraint(fields=('content_type', 'name'), name='extras_exporttemplate_unique_content_type_name'), + ), + migrations.AddConstraint( + model_name='webhook', + constraint=models.UniqueConstraint(fields=('payload_url', 'type_create', 'type_update', 'type_delete'), name='extras_webhook_unique_payload_url_types'), + ), + ] diff --git a/netbox/extras/migrations/0079_scheduled_jobs.py b/netbox/extras/migrations/0079_scheduled_jobs.py new file mode 100644 index 000000000..f9f8c6357 --- /dev/null +++ b/netbox/extras/migrations/0079_scheduled_jobs.py @@ -0,0 +1,31 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0078_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='jobresult', + name='scheduled', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='jobresult', + name='interval', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='jobresult', + name='started', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterModelOptions( + name='jobresult', + options={'ordering': ['-created']}, + ), + ] diff --git a/netbox/extras/migrations/0080_customlink_content_types.py b/netbox/extras/migrations/0080_customlink_content_types.py new file mode 100644 index 000000000..7f8456c67 --- /dev/null +++ b/netbox/extras/migrations/0080_customlink_content_types.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + CustomLink = apps.get_model('extras', 'CustomLink') + + for customlink in CustomLink.objects.all(): + customlink.content_types.set([customlink.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0079_scheduled_jobs'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='content_types', + field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='customlink', + name='content_type', + ), + ] diff --git a/netbox/extras/migrations/0081_exporttemplate_content_types.py b/netbox/extras/migrations/0081_exporttemplate_content_types.py new file mode 100644 index 000000000..afa21c5b8 --- /dev/null +++ b/netbox/extras/migrations/0081_exporttemplate_content_types.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + ExportTemplate = apps.get_model('extras', 'ExportTemplate') + + for et in ExportTemplate.objects.all(): + et.content_types.set([et.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0080_customlink_content_types'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='content_types', + field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveConstraint( + model_name='exporttemplate', + name='extras_exporttemplate_unique_content_type_name', + ), + migrations.RemoveField( + model_name='exporttemplate', + name='content_type', + ), + migrations.AlterModelOptions( + name='exporttemplate', + options={'ordering': ('name',)}, + ), + ] diff --git a/netbox/extras/migrations/0082_savedfilter.py b/netbox/extras/migrations/0082_savedfilter.py new file mode 100644 index 000000000..e2626ec6a --- /dev/null +++ b/netbox/extras/migrations/0082_savedfilter.py @@ -0,0 +1,35 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0081_exporttemplate_content_types'), + ] + + operations = [ + migrations.CreateModel( + name='SavedFilter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('weight', models.PositiveSmallIntegerField(default=100)), + ('enabled', models.BooleanField(default=True)), + ('shared', models.BooleanField(default=True)), + ('parameters', models.JSONField()), + ('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('weight', 'name'), + }, + ), + ] diff --git a/netbox/extras/migrations/0083_search.py b/netbox/extras/migrations/0083_search.py new file mode 100644 index 000000000..8f67717bb --- /dev/null +++ b/netbox/extras/migrations/0083_search.py @@ -0,0 +1,55 @@ +import sys +import uuid + +import django.db.models.deletion +import django.db.models.lookups +from django.core import management +from django.db import migrations, models + + +def reindex(apps, schema_editor): + # Build the search index (except during tests) + if 'test' not in sys.argv: + management.call_command('reindex') + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0041_standardize_description_comments'), + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0166_virtualdevicecontext'), + ('extras', '0082_savedfilter'), + ('ipam', '0063_standardize_description_comments'), + ('tenancy', '0009_standardize_description_comments'), + ('virtualization', '0034_standardize_description_comments'), + ('wireless', '0008_wirelesslan_status'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='search_weight', + field=models.PositiveSmallIntegerField(default=1000), + ), + migrations.CreateModel( + name='CachedValue', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveBigIntegerField()), + ('field', models.CharField(max_length=200)), + ('type', models.CharField(max_length=30)), + ('value', models.TextField()), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ('weight', 'object_type', 'object_id'), + }, + ), + migrations.RunPython( + code=reindex, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/migrations/0084_staging.py b/netbox/extras/migrations/0084_staging.py new file mode 100644 index 000000000..3129d7f5b --- /dev/null +++ b/netbox/extras/migrations/0084_staging.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0083_search'), + ] + + operations = [ + migrations.CreateModel( + name='Branch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='StagedChange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('action', models.CharField(max_length=20)), + ('object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('data', models.JSONField(blank=True, null=True)), + ('branch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staged_changes', to='extras.branch')), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ('pk',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 3cb6372be..9b5c660c4 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -2,9 +2,13 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField from .models import * +from .search import * +from .staging import * from .tags import Tag, TaggedItem __all__ = ( + 'Branch', + 'CachedValue', 'ConfigContext', 'ConfigContextModel', 'ConfigRevision', @@ -16,7 +20,9 @@ __all__ = ( 'JournalEntry', 'ObjectChange', 'Report', + 'SavedFilter', 'Script', + 'StagedChange', 'Tag', 'TaggedItem', 'Webhook', diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 30fb07069..d8d3510d7 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -116,6 +117,10 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:configcontext', kwargs={'pk': self.pk}) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/configcontext/' + def clean(self): super().clean() diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index d52d73848..14b033bcd 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,8 +1,10 @@ import re from datetime import datetime, date +import decimal import django_filters from django import forms +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError @@ -10,11 +12,13 @@ from django.db import models from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from extras.choices import * from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin +from netbox.search import FieldTypes from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -29,6 +33,15 @@ __all__ = ( 'CustomFieldManager', ) +SEARCH_TYPES = { + CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING, + CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING, + CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER, + CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT, + CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING, + CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING, +} + class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True @@ -46,25 +59,25 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge to=ContentType, related_name='custom_fields', limit_choices_to=FeatureQuery('custom_fields'), - help_text='The object(s) to which this field applies.' + help_text=_('The object(s) to which this field applies.') ) type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, default=CustomFieldTypeChoices.TYPE_TEXT, - help_text='The type of data this custom field holds' + help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( to=ContentType, on_delete=models.PROTECT, blank=True, null=True, - help_text='The type of NetBox object this field maps to (for object fields)' + help_text=_('The type of NetBox object this field maps to (for object fields)') ) name = models.CharField( max_length=50, unique=True, - help_text='Internal field name', + help_text=_('Internal field name'), validators=( RegexValidator( regex=r'^[a-z0-9_]+$', @@ -76,13 +89,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge label = models.CharField( max_length=50, blank=True, - help_text='Name of the field as displayed to users (if not provided, ' - 'the field\'s name will be used)' + help_text=_('Name of the field as displayed to users (if not provided, ' + 'the field\'s name will be used)') ) group_name = models.CharField( max_length=50, blank=True, - help_text="Custom fields within the same group will be displayed together" + help_text=_("Custom fields within the same group will be displayed together") ) description = models.CharField( max_length=200, @@ -90,65 +103,72 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge ) required = models.BooleanField( default=False, - help_text='If true, this field is required when creating new objects ' - 'or editing an existing object.' + help_text=_('If true, this field is required when creating new objects ' + 'or editing an existing object.') + ) + search_weight = models.PositiveSmallIntegerField( + default=1000, + help_text=_('Weighting for search. Lower values are considered more important. ' + 'Fields with a search weight of zero will be ignored.') ) filter_logic = models.CharField( max_length=50, choices=CustomFieldFilterLogicChoices, default=CustomFieldFilterLogicChoices.FILTER_LOOSE, - help_text='Loose matches any instance of a given string; exact ' - 'matches the entire field.' + help_text=_('Loose matches any instance of a given string; exact ' + 'matches the entire field.') ) default = models.JSONField( blank=True, null=True, - help_text='Default value for the field (must be a JSON value). Encapsulate ' - 'strings with double quotes (e.g. "Foo").' + help_text=_('Default value for the field (must be a JSON value). Encapsulate ' + 'strings with double quotes (e.g. "Foo").') ) weight = models.PositiveSmallIntegerField( default=100, - help_text='Fields with higher weights appear lower in a form.' + verbose_name='Display weight', + help_text=_('Fields with higher weights appear lower in a form.') ) validation_minimum = models.IntegerField( blank=True, null=True, verbose_name='Minimum value', - help_text='Minimum allowed value (for numeric fields)' + help_text=_('Minimum allowed value (for numeric fields)') ) validation_maximum = models.IntegerField( blank=True, null=True, verbose_name='Maximum value', - help_text='Maximum allowed value (for numeric fields)' + help_text=_('Maximum allowed value (for numeric fields)') ) validation_regex = models.CharField( blank=True, validators=[validate_regex], max_length=500, verbose_name='Validation regex', - help_text='Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. ' - 'For example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' + help_text=_('Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. ' + 'For example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.') ) choices = ArrayField( base_field=models.CharField(max_length=100), blank=True, null=True, - help_text='Comma-separated list of available choices (for selection fields)' + help_text=_('Comma-separated list of available choices (for selection fields)') ) ui_visibility = models.CharField( max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, verbose_name='UI visibility', - help_text='Specifies the visibility of custom field in the UI' + help_text=_('Specifies the visibility of custom field in the UI') ) objects = CustomFieldManager() clone_fields = ( - 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default', - 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility', + 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', + 'ui_visibility', ) class Meta: @@ -160,12 +180,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge def get_absolute_url(self): return reverse('extras:customfield', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/customfield/' + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Cache instance's original name so we can check later whether it has changed self._name = self.name + @property + def search_type(self): + return SEARCH_TYPES.get(self.type) + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or @@ -219,14 +247,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge }) # Minimum/maximum values can be set only for numeric fields - if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: - raise ValidationError({ - 'validation_minimum': "A minimum value may be set only for numeric fields" - }) - if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: - raise ValidationError({ - 'validation_maximum': "A maximum value may be set only for numeric fields" - }) + if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): + if self.validation_minimum: + raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"}) + if self.validation_maximum: + raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"}) # Regex validation can be set only for text fields regex_types = ( @@ -277,6 +302,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ if value is None: return value + if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date: + return value.isoformat() if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: @@ -289,6 +316,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ if value is None: return value + if self.type == CustomFieldTypeChoices.TYPE_DATE: + try: + return date.fromisoformat(value) + except ValueError: + return value if self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() return model.objects.filter(pk=value).first() @@ -318,6 +350,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge max_value=self.validation_maximum ) + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + field = forms.DecimalField( + required=required, + initial=initial, + max_digits=12, + decimal_places=4, + min_value=self.validation_minimum, + max_value=self.validation_maximum + ) + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( @@ -433,6 +476,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: filter_class = filters.MultiValueNumberFilter + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + filter_class = filters.MultiValueDecimalFilter + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: filter_class = django_filters.BooleanFilter @@ -482,7 +529,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError(f"Value must match regex '{self.validation_regex}'") # Validate integer - if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: if type(value) is not int: raise ValidationError("Value must be an integer.") if self.validation_minimum is not None and value < self.validation_minimum: @@ -490,12 +537,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + try: + decimal.Decimal(value) + except decimal.InvalidOperation: + raise ValidationError("Value must be a decimal.") + if self.validation_minimum is not None and value < self.validation_minimum: + raise ValidationError(f"Value must be at least {self.validation_minimum}") + if self.validation_maximum is not None and value > self.validation_maximum: + raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate boolean - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError("Value must be true or false.") # Validate date - if self.type == CustomFieldTypeChoices.TYPE_DATE: + elif self.type == CustomFieldTypeChoices.TYPE_DATE: if type(value) is not date: try: datetime.strptime(value, '%Y-%m-%d') @@ -503,14 +561,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError("Date values must be in the format YYYY-MM-DD.") # Validate selected choice - if self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: if value not in self.choices: raise ValidationError( f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) # Validate all selected choices - if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if not set(value).issubset(self.choices): raise ValidationError( f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 1bcc91d62..6a60458e2 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,17 +1,19 @@ import json import uuid +from django.conf import settings from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.core.validators import ValidationError +from django.core.validators import MinValueValidator, ValidationError from django.db import models -from django.http import HttpResponse +from django.http import HttpResponse, QueryDict from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format +from django.utils.translation import gettext as _ from rest_framework.utils.encoders import JSONEncoder import django_rq @@ -19,6 +21,8 @@ from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload +from netbox.config import get_config +from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models import ChangeLoggedModel from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, @@ -34,6 +38,7 @@ __all__ = ( 'JobResult', 'JournalEntry', 'Report', + 'SavedFilter', 'Script', 'Webhook', ) @@ -50,7 +55,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): related_name='webhooks', verbose_name='Object types', limit_choices_to=FeatureQuery('webhooks'), - help_text="The object(s) to which this Webhook applies." + help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( max_length=150, @@ -58,21 +63,21 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) type_create = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is created." + help_text=_("Call this webhook when a matching object is created.") ) type_update = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is updated." + help_text=_("Call this webhook when a matching object is updated.") ) type_delete = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is deleted." + help_text=_("Call this webhook when a matching object is deleted.") ) payload_url = models.CharField( max_length=500, verbose_name='URL', - help_text='This URL will be called using the HTTP method defined when the webhook is called. ' - 'Jinja2 template processing is supported with the same context as the request body.' + help_text=_('This URL will be called using the HTTP method defined when the webhook is called. ' + 'Jinja2 template processing is supported with the same context as the request body.') ) enabled = models.BooleanField( default=True @@ -87,51 +92,56 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): max_length=100, default=HTTP_CONTENT_TYPE_JSON, verbose_name='HTTP content type', - help_text='The complete list of official content types is available ' - 'here.' + help_text=_('The complete list of official content types is available ' + 'here.') ) additional_headers = models.TextField( blank=True, - help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. " - "Headers should be defined in the format Name: Value. Jinja2 template processing is " - "supported with the same context as the request body (below)." + help_text=_("User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. " + "Headers should be defined in the format Name: Value. Jinja2 template processing is " + "supported with the same context as the request body (below).") ) body_template = models.TextField( blank=True, - help_text='Jinja2 template for a custom request body. If blank, a JSON object representing the change will be ' - 'included. Available context data includes: event, model, ' - 'timestamp, username, request_id, and data.' + help_text=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be ' + 'included. Available context data includes: event, model, ' + 'timestamp, username, request_id, and data.') ) secret = models.CharField( max_length=255, blank=True, - help_text="When provided, the request will include a 'X-Hook-Signature' " - "header containing a HMAC hex digest of the payload body using " - "the secret as the key. The secret is not transmitted in " - "the request." + help_text=_("When provided, the request will include a 'X-Hook-Signature' " + "header containing a HMAC hex digest of the payload body using " + "the secret as the key. The secret is not transmitted in " + "the request.") ) conditions = models.JSONField( blank=True, null=True, - help_text="A set of conditions which determine whether the webhook will be generated." + help_text=_("A set of conditions which determine whether the webhook will be generated.") ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', - help_text="Enable SSL certificate verification. Disable with caution!" + help_text=_("Enable SSL certificate verification. Disable with caution!") ) ca_file_path = models.CharField( max_length=4096, null=True, blank=True, verbose_name='CA File Path', - help_text='The specific CA certificate file to use for SSL verification. ' - 'Leave blank to use the system defaults.' + help_text=_('The specific CA certificate file to use for SSL verification. ' + 'Leave blank to use the system defaults.') ) class Meta: ordering = ('name',) - unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) + constraints = ( + models.UniqueConstraint( + fields=('payload_url', 'type_create', 'type_update', 'type_delete'), + name='%(app_label)s_%(class)s_unique_payload_url_types' + ), + ) def __str__(self): return self.name @@ -139,6 +149,10 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:webhook', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/webhook/' + def clean(self): super().clean() @@ -192,10 +206,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. """ - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('custom_links') + related_name='custom_links', + help_text=_('The object type(s) to which this link applies.') ) name = models.CharField( max_length=100, @@ -205,11 +219,11 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged default=True ) link_text = models.TextField( - help_text="Jinja2 template code for link text" + help_text=_("Jinja2 template code for link text") ) link_url = models.TextField( verbose_name='Link URL', - help_text="Jinja2 template code for link URL" + help_text=_("Jinja2 template code for link URL") ) weight = models.PositiveSmallIntegerField( default=100 @@ -217,21 +231,21 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged group_name = models.CharField( max_length=50, blank=True, - help_text="Links with the same group will appear as a dropdown menu" + help_text=_("Links with the same group will appear as a dropdown menu") ) button_class = models.CharField( max_length=30, choices=CustomLinkButtonClassChoices, default=CustomLinkButtonClassChoices.DEFAULT, - help_text="The class of the first link in a group will be used for the dropdown button" + help_text=_("The class of the first link in a group will be used for the dropdown button") ) new_window = models.BooleanField( default=False, - help_text="Force link to open in a new window" + help_text=_("Force link to open in a new window") ) clone_fields = ( - 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: @@ -243,6 +257,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged def get_absolute_url(self): return reverse('extras:customlink', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/customlink/' + def render(self, context): """ Render the CustomLink given the provided context, and return the text, link, and link_target. @@ -263,10 +281,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('export_templates') + related_name='export_templates', + help_text=_('The object type(s) to which this template applies.') ) name = models.CharField( max_length=100 @@ -276,37 +294,38 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): blank=True ) template_code = models.TextField( - help_text='Jinja2 template code. The list of objects being exported is passed as a context variable named ' - 'queryset.' + help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named ' + 'queryset.') ) mime_type = models.CharField( max_length=50, blank=True, verbose_name='MIME type', - help_text='Defaults to text/plain' + help_text=_('Defaults to text/plain') ) file_extension = models.CharField( max_length=15, blank=True, - help_text='Extension to append to the rendered filename' + help_text=_('Extension to append to the rendered filename') ) as_attachment = models.BooleanField( default=True, - help_text="Download file as attachment" + help_text=_("Download file as attachment") ) class Meta: - ordering = ['content_type', 'name'] - unique_together = [ - ['content_type', 'name'] - ] + ordering = ('name',) def __str__(self): - return f"{self.content_type}: {self.name}" + return self.name def get_absolute_url(self): return reverse('extras:exporttemplate', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/exporttemplate/' + def clean(self): super().clean() @@ -348,6 +367,77 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return response +class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): + """ + A set of predefined keyword parameters that can be reused to filter for specific objects. + """ + content_types = models.ManyToManyField( + to=ContentType, + related_name='saved_filters', + help_text=_('The object type(s) to which this filter applies.') + ) + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True + ) + weight = models.PositiveSmallIntegerField( + default=100 + ) + enabled = models.BooleanField( + default=True + ) + shared = models.BooleanField( + default=True + ) + parameters = models.JSONField() + + clone_fields = ( + 'enabled', 'weight', + ) + + class Meta: + ordering = ('weight', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:savedfilter', args=[self.pk]) + + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/savedfilter/' + + def clean(self): + super().clean() + + # Verify that `parameters` is a JSON object + if type(self.parameters) is not dict: + raise ValidationError( + {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'} + ) + + @property + def url_params(self): + qd = QueryDict(mutable=True) + qd.update(self.parameters) + return qd.urlencode() + + class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. @@ -487,12 +577,28 @@ class JobResult(models.Model): related_name='job_results', verbose_name='Object types', limit_choices_to=FeatureQuery('job_results'), - help_text="The object type to which this job result applies", + help_text=_("The object type to which this job result applies"), on_delete=models.CASCADE, ) created = models.DateTimeField( auto_now_add=True ) + scheduled = models.DateTimeField( + null=True, + blank=True + ) + interval = models.PositiveIntegerField( + blank=True, + null=True, + validators=( + MinValueValidator(1), + ), + help_text=_("Recurrence interval (in minutes)") + ) + started = models.DateTimeField( + null=True, + blank=True + ) completed = models.DateTimeField( null=True, blank=True @@ -517,12 +623,29 @@ class JobResult(models.Model): unique=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: - ordering = ['obj_type', 'name', '-created'] + ordering = ['-created'] def __str__(self): return str(self.job_id) + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + + queue = django_rq.get_queue("default") + job = queue.fetch_job(str(self.job_id)) + + if job: + job.cancel() + + def get_absolute_url(self): + return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk]) + + def get_status_color(self): + return JobResultStatusChoices.colors.get(self.status) + @property def duration(self): if not self.completed: @@ -533,9 +656,18 @@ class JobResult(models.Model): return f"{int(minutes)} minutes, {seconds:.2f} seconds" + def start(self): + """ + Record the job's start time and update its status to "running." + """ + if self.started is None: + self.started = timezone.now() + self.status = JobResultStatusChoices.STATUS_RUNNING + JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status) + def set_status(self, status): """ - Helper method to change the status of the job result. If the target status is terminal, the completion + Helper method to change the status of the job result. If the target status is terminal, the completion time is also set. """ self.status = status @@ -543,26 +675,35 @@ class JobResult(models.Model): self.completed = timezone.now() @classmethod - def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs): + def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs): """ Create a JobResult instance and enqueue a job using the given callable - func: The callable object to be enqueued for execution - name: Name for the JobResult instance - obj_type: ContentType to link to the JobResult instance obj_type - user: User object to link to the JobResult instance - args: additional args passed to the callable - kwargs: additional kargs passed to the callable + Args: + func: The callable object to be enqueued for execution + name: Name for the JobResult instance + obj_type: ContentType to link to the JobResult instance obj_type + user: User object to link to the JobResult instance + schedule_at: Schedule the job to be executed at the passed date and time + interval: Recurrence interval (in minutes) """ - job_result = cls.objects.create( + rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT) + queue = django_rq.get_queue(rq_queue_name) + status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING + job_result: JobResult = JobResult.objects.create( name=name, + status=status, obj_type=obj_type, + scheduled=schedule_at, + interval=interval, user=user, job_id=uuid.uuid4() ) - queue = django_rq.get_queue("default") - queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + if schedule_at: + queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + else: + queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py new file mode 100644 index 000000000..7c5860e00 --- /dev/null +++ b/netbox/extras/models/search.py @@ -0,0 +1,48 @@ +import uuid + +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from utilities.fields import RestrictedGenericForeignKey + +__all__ = ( + 'CachedValue', +) + + +class CachedValue(models.Model): + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False + ) + timestamp = models.DateTimeField( + auto_now_add=True, + editable=False + ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + object_id = models.PositiveBigIntegerField() + object = RestrictedGenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + field = models.CharField( + max_length=200 + ) + type = models.CharField( + max_length=30 + ) + value = models.TextField() + weight = models.PositiveSmallIntegerField( + default=1000 + ) + + class Meta: + ordering = ('weight', 'object_type', 'object_id') + + def __str__(self): + return f'{self.object_type} {self.object_id}: {self.field}={self.value}' diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py new file mode 100644 index 000000000..b46d6a7bc --- /dev/null +++ b/netbox/extras/models/staging.py @@ -0,0 +1,114 @@ +import logging + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction + +from extras.choices import ChangeActionChoices +from netbox.models import ChangeLoggedModel +from utilities.utils import deserialize_object + +__all__ = ( + 'Branch', + 'StagedChange', +) + +logger = logging.getLogger('netbox.staging') + + +class Branch(ChangeLoggedModel): + """ + A collection of related StagedChanges. + """ + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=get_user_model(), + on_delete=models.SET_NULL, + blank=True, + null=True + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return f'{self.name} ({self.pk})' + + def merge(self): + logger.info(f'Merging changes in branch {self}') + with transaction.atomic(): + for change in self.staged_changes.all(): + change.apply() + self.staged_changes.all().delete() + + +class StagedChange(ChangeLoggedModel): + """ + The prepared creation, modification, or deletion of an object to be applied to the active database at a + future point. + """ + branch = models.ForeignKey( + to=Branch, + on_delete=models.CASCADE, + related_name='staged_changes' + ) + action = models.CharField( + max_length=20, + choices=ChangeActionChoices + ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + object_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + object = GenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + data = models.JSONField( + blank=True, + null=True + ) + + class Meta: + ordering = ('pk',) + + def __str__(self): + action = self.get_action_display() + app_label, model_name = self.object_type.natural_key() + return f"{action} {app_label}.{model_name} ({self.object_id})" + + @property + def model(self): + return self.object_type.model_class() + + def apply(self): + """ + Apply the staged create/update/delete action to the database. + """ + if self.action == ChangeActionChoices.ACTION_CREATE: + instance = deserialize_object(self.model, self.data, pk=self.object_id) + logger.info(f'Creating {self.model._meta.verbose_name} {instance}') + instance.save() + + if self.action == ChangeActionChoices.ACTION_UPDATE: + instance = deserialize_object(self.model, self.data, pk=self.object_id) + logger.info(f'Updating {self.model._meta.verbose_name} {instance}') + instance.save() + + if self.action == ChangeActionChoices.ACTION_DELETE: + instance = self.model.objects.get(pk=self.object_id) + logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') + instance.delete() diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index a4e4049d7..827d969e3 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models from django.urls import reverse from django.utils.text import slugify @@ -31,6 +32,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): def get_absolute_url(self): return reverse('extras:tag', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/tag/' + def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 0b57e6f05..7694a1fbe 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,20 +1,21 @@ import collections -import inspect -from packaging import version from django.apps import AppConfig +from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.template.loader import get_template - -from extras.registry import registry -from utilities.choices import ButtonColorChoices - -from extras.plugins.utils import import_object +from django.utils.module_loading import import_string +from packaging import version +from netbox.registry import registry +from netbox.search import register_search +from .navigation import * +from .registration import * +from .templates import * # Initialize plugin registry registry['plugins'] = { 'graphql_schemas': [], + 'menus': [], 'menu_items': {}, 'preferences': {}, 'template_extensions': collections.defaultdict(list), @@ -54,9 +55,14 @@ class PluginConfig(AppConfig): # Django-rq queues dedicated to the plugin queues = [] + # Django apps to append to INSTALLED_APPS when plugin requires them. + django_apps = [] + # Default integration paths. Plugin authors can override these to customize the paths to # integrated components. + search_indexes = 'search.indexes' graphql_schema = 'graphql.schema' + menu = 'navigation.menu' menu_items = 'navigation.menu_items' template_extensions = 'template_content.template_extensions' user_preferences = 'preferences.preferences' @@ -64,25 +70,46 @@ class PluginConfig(AppConfig): def ready(self): plugin_name = self.name.rsplit('.', 1)[-1] - # Register template content (if defined) - template_extensions = import_object(f"{self.__module__}.{self.template_extensions}") - if template_extensions is not None: - register_template_extensions(template_extensions) + # Register search extensions (if defined) + try: + search_indexes = import_string(f"{self.__module__}.{self.search_indexes}") + for idx in search_indexes: + register_search(idx) + except ImportError: + pass - # Register navigation menu items (if defined) - menu_items = import_object(f"{self.__module__}.{self.menu_items}") - if menu_items is not None: + # Register template content (if defined) + try: + template_extensions = import_string(f"{self.__module__}.{self.template_extensions}") + register_template_extensions(template_extensions) + except ImportError: + pass + + # Register navigation menu and/or menu items (if defined) + try: + menu = import_string(f"{self.__module__}.{self.menu}") + register_menu(menu) + except ImportError: + pass + try: + menu_items = import_string(f"{self.__module__}.{self.menu_items}") register_menu_items(self.verbose_name, menu_items) + except ImportError: + pass # Register GraphQL schema (if defined) - graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") - if graphql_schema is not None: + try: + graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}") register_graphql_schema(graphql_schema) + except ImportError: + pass # Register user preferences (if defined) - user_preferences = import_object(f"{self.__module__}.{self.user_preferences}") - if user_preferences is not None: + try: + user_preferences = import_string(f"{self.__module__}.{self.user_preferences}") register_user_preferences(plugin_name, user_preferences) + except ImportError: + pass @classmethod def validate(cls, user_config, netbox_version): @@ -117,167 +144,20 @@ class PluginConfig(AppConfig): # -# Template content injection +# Utilities # -class PluginTemplateExtension: +def get_plugin_config(plugin_name, parameter, default=None): """ - This class is used to register plugin content to be injected into core NetBox templates. It contains methods - that are overridden by plugin authors to return template content. + Return the value of the specified plugin configuration parameter. - The `model` attribute on the class defines the which model detail page this class renders content for. It - should be set as a string in the form '.'. render() provides the following context data: - - * object - The object being viewed - * request - The current request - * settings - Global NetBox settings - * config - Plugin-specific configuration parameters + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) """ - model = None - - def __init__(self, context): - self.context = context - - def render(self, template_name, extra_context=None): - """ - Convenience method for rendering the specified Django template using the default context data. An additional - context dictionary may be passed as `extra_context`. - """ - if extra_context is None: - extra_context = {} - elif not isinstance(extra_context, dict): - raise TypeError("extra_context must be a dictionary") - - return get_template(template_name).render({**self.context, **extra_context}) - - def left_page(self): - """ - Content that will be rendered on the left of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def right_page(self): - """ - Content that will be rendered on the right of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def full_width_page(self): - """ - Content that will be rendered within the full width of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError - - -def register_template_extensions(class_list): - """ - Register a list of PluginTemplateExtension classes - """ - # Validation - for template_extension in class_list: - if not inspect.isclass(template_extension): - raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") - if not issubclass(template_extension, PluginTemplateExtension): - raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") - if template_extension.model is None: - raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") - - registry['plugins']['template_extensions'][template_extension.model].append(template_extension) - - -# -# Navigation menu links -# - -class PluginMenuItem: - """ - This class represents a navigation menu item. This constitutes primary link and its text, but also allows for - specifying additional link buttons that appear to the right of the item in the van menu. - - Links are specified as Django reverse URL strings. - Buttons are each specified as a list of PluginMenuButton instances. - """ - permissions = [] - buttons = [] - - def __init__(self, link, link_text, permissions=None, buttons=None): - self.link = link - self.link_text = link_text - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if buttons is not None: - if type(buttons) not in (list, tuple): - raise TypeError("Buttons must be passed as a tuple or list.") - self.buttons = buttons - - -class PluginMenuButton: - """ - This class represents a button within a PluginMenuItem. Note that button colors should come from - ButtonColorChoices. - """ - color = ButtonColorChoices.DEFAULT - permissions = [] - - def __init__(self, link, title, icon_class, color=None, permissions=None): - self.link = link - self.title = title - self.icon_class = icon_class - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if color is not None: - if color not in ButtonColorChoices.values(): - raise ValueError("Button color must be a choice within ButtonColorChoices.") - self.color = color - - -def register_menu_items(section_name, class_list): - """ - Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) - """ - # Validation - for menu_link in class_list: - if not isinstance(menu_link, PluginMenuItem): - raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") - for button in menu_link.buttons: - if not isinstance(button, PluginMenuButton): - raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - - registry['plugins']['menu_items'][section_name] = class_list - - -# -# GraphQL schemas -# - -def register_graphql_schema(graphql_schema): - """ - Register a GraphQL schema class for inclusion in NetBox's GraphQL API. - """ - registry['plugins']['graphql_schemas'].append(graphql_schema) - - -# -# User preferences -# - -def register_user_preferences(plugin_name, preferences): - """ - Register a list of user preferences defined by a plugin. - """ - registry['plugins']['preferences'][plugin_name] = preferences + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py new file mode 100644 index 000000000..e667965b8 --- /dev/null +++ b/netbox/extras/plugins/navigation.py @@ -0,0 +1,70 @@ +from netbox.navigation import MenuGroup +from utilities.choices import ButtonColorChoices + +__all__ = ( + 'PluginMenu', + 'PluginMenuButton', + 'PluginMenuItem', +) + + +class PluginMenu: + icon_class = 'mdi mdi-puzzle' + + def __init__(self, label, groups, icon_class=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon_class is not None: + self.icon_class = icon_class + + @property + def name(self): + return self.label.replace(' ', '_') + + +class PluginMenuItem: + """ + This class represents a navigation menu item. This constitutes primary link and its text, but also allows for + specifying additional link buttons that appear to the right of the item in the van menu. + + Links are specified as Django reverse URL strings. + Buttons are each specified as a list of PluginMenuButton instances. + """ + permissions = [] + buttons = [] + + def __init__(self, link, link_text, permissions=None, buttons=None): + self.link = link + self.link_text = link_text + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if buttons is not None: + if type(buttons) not in (list, tuple): + raise TypeError("Buttons must be passed as a tuple or list.") + self.buttons = buttons + + +class PluginMenuButton: + """ + This class represents a button within a PluginMenuItem. Note that button colors should come from + ButtonColorChoices. + """ + color = ButtonColorChoices.DEFAULT + permissions = [] + + def __init__(self, link, title, icon_class, color=None, permissions=None): + self.link = link + self.title = title + self.icon_class = icon_class + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if color is not None: + if color not in ButtonColorChoices.values(): + raise ValueError("Button color must be a choice within ButtonColorChoices.") + self.color = color diff --git a/netbox/extras/plugins/registration.py b/netbox/extras/plugins/registration.py new file mode 100644 index 000000000..5b7e58172 --- /dev/null +++ b/netbox/extras/plugins/registration.py @@ -0,0 +1,64 @@ +import inspect + +from netbox.registry import registry +from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem +from .templates import PluginTemplateExtension + +__all__ = ( + 'register_graphql_schema', + 'register_menu', + 'register_menu_items', + 'register_template_extensions', + 'register_user_preferences', +) + + +def register_template_extensions(class_list): + """ + Register a list of PluginTemplateExtension classes + """ + # Validation + for template_extension in class_list: + if not inspect.isclass(template_extension): + raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") + if not issubclass(template_extension, PluginTemplateExtension): + raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") + if template_extension.model is None: + raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") + + registry['plugins']['template_extensions'][template_extension.model].append(template_extension) + + +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): + """ + Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) + """ + # Validation + for menu_link in class_list: + if not isinstance(menu_link, PluginMenuItem): + raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") + for button in menu_link.buttons: + if not isinstance(button, PluginMenuButton): + raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") + + registry['plugins']['menu_items'][section_name] = class_list + + +def register_graphql_schema(graphql_schema): + """ + Register a GraphQL schema class for inclusion in NetBox's GraphQL API. + """ + registry['plugins']['graphql_schemas'].append(graphql_schema) + + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/extras/plugins/templates.py b/netbox/extras/plugins/templates.py new file mode 100644 index 000000000..e9b9a9dca --- /dev/null +++ b/netbox/extras/plugins/templates.py @@ -0,0 +1,73 @@ +from django.template.loader import get_template + +__all__ = ( + 'PluginTemplateExtension', +) + + +class PluginTemplateExtension: + """ + This class is used to register plugin content to be injected into core NetBox templates. It contains methods + that are overridden by plugin authors to return template content. + + The `model` attribute on the class defines the which model detail page this class renders content for. It + should be set as a string in the form '.'. render() provides the following context data: + + * object - The object being viewed + * request - The current request + * settings - Global NetBox settings + * config - Plugin-specific configuration parameters + """ + model = None + + def __init__(self, context): + self.context = context + + def render(self, template_name, extra_context=None): + """ + Convenience method for rendering the specified Django template using the default context data. An additional + context dictionary may be passed as `extra_context`. + """ + if extra_context is None: + extra_context = {} + elif not isinstance(extra_context, dict): + raise TypeError("extra_context must be a dictionary") + + return get_template(template_name).render({**self.context, **extra_context}) + + def left_page(self): + """ + Content that will be rendered on the left of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def right_page(self): + """ + Content that will be rendered on the right of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def full_width_page(self): + """ + Content that will be rendered within the full width of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError + + def list_buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the list view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index 7ab293916..b4360dc9e 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -3,8 +3,7 @@ from django.conf import settings from django.conf.urls import include from django.contrib.admin.views.decorators import staff_member_required from django.urls import path - -from extras.plugins.utils import import_object +from django.utils.module_loading import import_string from . import views @@ -25,15 +24,19 @@ for plugin_path in settings.PLUGINS: base_url = getattr(app, 'base_url') or app.label # Check if the plugin specifies any base URLs - urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns") - if urlpatterns is not None: + try: + urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") plugin_patterns.append( path(f"{base_url}/", include((urlpatterns, app.label))) ) + except ImportError: + pass # Check if the plugin specifies any API URLs - urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns") - if urlpatterns is not None: + try: + urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") plugin_api_patterns.append( path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) ) + except ImportError: + pass diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py deleted file mode 100644 index 87240aba8..000000000 --- a/netbox/extras/plugins/utils.py +++ /dev/null @@ -1,33 +0,0 @@ -import importlib.util -import sys - - -def import_object(module_and_object): - """ - Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object". - - Returns the imported object, or None if it doesn't exist. - """ - target_module_name, object_name = module_and_object.rsplit('.', 1) - module_hierarchy = target_module_name.split('.') - - # Iterate through the module hierarchy, checking for the existence of each successive submodule. - # We have to do this rather than jumping directly to calling find_spec(target_module_name) - # because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist. - module_name = "" - for module_component in module_hierarchy: - module_name = f"{module_name}.{module_component}" if module_name else module_component - spec = importlib.util.find_spec(module_name) - if spec is None: - # No such module - return None - - # Okay, target_module_name exists. Load it if not already loaded - if target_module_name in sys.modules: - module = sys.modules[target_module_name] - else: - module = importlib.util.module_from_spec(spec) - sys.modules[target_module_name] = module - spec.loader.exec_module(module) - - return getattr(module, object_name, None) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 32e4efc2d..37c78dd18 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,8 +1,8 @@ -import importlib import inspect import logging import pkgutil import traceback +from datetime import timedelta from django.conf import settings from django.utils import timezone @@ -11,7 +11,6 @@ from django_rq import job from .choices import JobResultStatusChoices, LogLevelChoices from .models import JobResult - logger = logging.getLogger(__name__) @@ -83,12 +82,26 @@ def run_report(job_result, *args, **kwargs): report = get_report(module_name, report_name) try: + job_result.start() report.run(job_result) - except Exception as e: - print(e) + except Exception: job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.save() logging.error(f"Error during execution of report {job_result.name}") + finally: + # Schedule the next job if an interval has been set + start_time = job_result.scheduled or job_result.started + if start_time and job_result.interval: + new_scheduled_time = start_time + timedelta(minutes=job_result.interval) + JobResult.enqueue_job( + run_report, + name=job_result.name, + obj_type=job_result.obj_type, + user=job_result.user, + job_timeout=report.job_timeout, + schedule_at=new_scheduled_time, + interval=job_result.interval + ) class Report(object): diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 23a778789..998d727a4 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -4,8 +4,9 @@ import logging import os import pkgutil import sys -import traceback import threading +import traceback +from datetime import timedelta import yaml from django import forms @@ -16,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.models import JobResult from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator @@ -433,16 +435,14 @@ def is_variable(obj): def run_script(data, request, commit=True, *args, **kwargs): """ A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It - exists outside of the Script class to ensure it cannot be overridden by a script author. + exists outside the Script class to ensure it cannot be overridden by a script author. """ job_result = kwargs.pop('job_result') + job_result.start() + module, script_name = job_result.name.split('.', 1) - script = get_script(module, script_name)() - job_result.status = JobResultStatusChoices.STATUS_RUNNING - job_result.save() - logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}") logger.info(f"Running script (commit={commit})") @@ -493,6 +493,22 @@ def run_script(data, request, commit=True, *args, **kwargs): else: _run_script() + # Schedule the next job if an interval has been set + if job_result.interval: + new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval) + JobResult.enqueue_job( + run_script, + name=job_result.name, + obj_type=job_result.obj_type, + user=job_result.user, + schedule_at=new_scheduled_time, + interval=job_result.interval, + job_timeout=script.job_timeout, + data=data, + request=request, + commit=commit + ) + def get_scripts(use_names=False): """ diff --git a/netbox/extras/search.py b/netbox/extras/search.py new file mode 100644 index 000000000..da4aa1c84 --- /dev/null +++ b/netbox/extras/search.py @@ -0,0 +1,11 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class JournalEntryIndex(SearchIndex): + model = models.JournalEntry + fields = ( + ('comments', 5000), + ) + category = 'Journal' diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 22da5f52a..c2b8c9424 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -1,5 +1,6 @@ import django_tables2 as tables from django.conf import settings +from django.utils.translation import gettext as _ from extras.models import * from netbox.tables import NetBoxTable, columns @@ -10,18 +11,16 @@ __all__ = ( 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', + 'JobResultTable', 'JournalEntryTable', 'ObjectChangeTable', + 'SavedFilterTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -# -# Custom fields -# - class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True @@ -33,58 +32,94 @@ class CustomFieldTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', + 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', + 'search_weight', 'filter_logic', 'ui_visibility', 'weight', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') -# -# Custom links -# +class JobResultTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + obj_type = columns.ContentTypeColumn( + verbose_name=_('Type') + ) + status = columns.ChoiceFieldColumn() + created = columns.DateTimeColumn() + scheduled = columns.DateTimeColumn() + interval = columns.DurationColumn() + started = columns.DateTimeColumn() + completed = columns.DateTimeColumn() + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = JobResult + fields = ( + 'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', + 'user', 'job_id', + ) + default_columns = ( + 'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', + 'user', + ) + class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = columns.ContentTypeColumn() + content_types = columns.ContentTypesColumn() enabled = columns.BooleanColumn() new_window = columns.BooleanColumn() class Meta(NetBoxTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') -# -# Export templates -# - class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = columns.ContentTypeColumn() + content_types = columns.ContentTypesColumn() as_attachment = columns.BooleanColumn() class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( - 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', ) -# -# Webhooks -# +class SavedFilterTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + shared = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = SavedFilter + fields = ( + 'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared', + ) + class WebhookTable(NetBoxTable): name = tables.Column( @@ -117,10 +152,6 @@ class WebhookTable(NetBoxTable): ) -# -# Tags -# - class TagTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index a73eb3fb4..b7d8d1448 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from extras.models import CustomLink -from utilities.utils import render_jinja2 register = template.Library() @@ -34,7 +33,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py index df3024a16..560d15e01 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/extras/templatetags/plugins.py @@ -3,7 +3,7 @@ from django.conf import settings from django.utils.safestring import mark_safe from extras.plugins import PluginTemplateExtension -from extras.registry import registry +from netbox.registry import registry register = template_.Library() @@ -73,3 +73,11 @@ def plugin_full_width_page(context, obj): Render all full width page content registered by plugins """ return _get_registered_content(obj, 'full_width_page', context) + + +@register.simple_tag(takes_context=True) +def plugin_list_buttons(context, model): + """ + Render all list buttons registered by plugins + """ + return _get_registered_content(model, 'list_buttons', context) diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py index 88ac3f7c9..a9157b368 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -1,7 +1,8 @@ -from extras.plugins import PluginMenuButton, PluginMenuItem +from django.utils.translation import gettext as _ +from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem -menu_items = ( +items = ( PluginMenuItem( link='plugins:dummy_plugin:dummy_models', link_text='Item 1', @@ -23,3 +24,9 @@ menu_items = ( link_text='Item 2', ), ) + +menu = PluginMenu( + label=_('Dummy Plugin'), + groups=(('Group 1', items),), +) +menu_items = items diff --git a/netbox/extras/tests/dummy_plugin/search.py b/netbox/extras/tests/dummy_plugin/search.py new file mode 100644 index 000000000..4b1c6f10e --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/search.py @@ -0,0 +1,14 @@ +from netbox.search import SearchIndex +from .models import DummyModel + + +class DummyModelIndex(SearchIndex): + model = DummyModel + fields = ( + ('name', 100), + ) + + +indexes = ( + DummyModelIndex, +) diff --git a/netbox/extras/tests/dummy_plugin/template_content.py b/netbox/extras/tests/dummy_plugin/template_content.py index 6151454ea..364768a22 100644 --- a/netbox/extras/tests/dummy_plugin/template_content.py +++ b/netbox/extras/tests/dummy_plugin/template_content.py @@ -16,5 +16,8 @@ class SiteContent(PluginTemplateExtension): def buttons(self): return "SITE CONTENT - BUTTONS" + def list_buttons(self): + return "SITE CONTENT - LIST BUTTONS" + template_extensions = [SiteContent] diff --git a/netbox/extras/tests/dummy_plugin/views.py b/netbox/extras/tests/dummy_plugin/views.py index 4512758df..8713102c5 100644 --- a/netbox/extras/tests/dummy_plugin/views.py +++ b/netbox/extras/tests/dummy_plugin/views.py @@ -1,6 +1,8 @@ from django.http import HttpResponse from django.views.generic import View +from dcim.models import Site +from utilities.views import register_model_view from .models import DummyModel @@ -9,3 +11,10 @@ class DummyModelsView(View): def get(self, request): instance_count = DummyModel.objects.count() return HttpResponse(f"Instances: {instance_count}") + + +@register_model_view(Site, 'extra', path='other-stuff') +class ExtraCoreModelView(View): + + def get(self, request, pk): + return HttpResponse("Success!") diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7a9ee487d..b959587ce 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,7 +3,6 @@ from unittest import skipIf from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from django.utils.timezone import make_aware from django_rq.queues import get_connection @@ -17,7 +16,6 @@ from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases - rq_worker_running = Worker.count(get_connection('default')) @@ -137,21 +135,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 4', 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 5', 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 6', 'enabled': False, 'link_text': 'Link 6', @@ -169,21 +167,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): custom_links = ( CustomLink( - content_type=site_ct, name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( - content_type=site_ct, name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( - content_type=site_ct, name='Custom Link 3', enabled=False, link_text='Link 3', @@ -191,6 +186,81 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) + + +class SavedFilterTest(APIViewTestCases.APIViewTestCase): + model = SavedFilter + brief_fields = ['display', 'id', 'name', 'slug', 'url'] + create_data = [ + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 4', + 'slug': 'saved-filter-4', + 'weight': 100, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['active']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 5', + 'slug': 'saved-filter-5', + 'weight': 200, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['planned']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 6', + 'slug': 'saved-filter-6', + 'weight': 300, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['retired']}, + }, + ] + bulk_update_data = { + 'weight': 1000, + 'enabled': False, + 'shared': False, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + slug='saved-filter-1', + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + slug='saved-filter-2', + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + slug='saved-filter-3', + weight=300, + enabled=True, + shared=True, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) class ExportTemplateTest(APIViewTestCases.APIViewTestCase): @@ -198,17 +268,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -219,26 +289,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - ct = ContentType.objects.get_for_model(Device) - export_templates = ( ExportTemplate( - content_type=ct, name='Export Template 1', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 2', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 3', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ) ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([ContentType.objects.get_for_model(Device)]) class TagTest(APIViewTestCases.APIViewTestCase): @@ -544,73 +611,76 @@ class ScriptTest(APITestCase): class CreatedUpdatedFilterTest(APITestCase): - def setUp(self): - - super().setUp() - - self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') - self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1') - self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') - self.rack1 = Rack.objects.create( - site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42, - ) - self.rack2 = Rack.objects.create( - site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42, + @classmethod + def setUpTestData(cls): + site1 = Site.objects.create(name='Site 1', slug='site-1') + location1 = Location.objects.create(site=site1, name='Location 1', slug='location-1') + rackrole1 = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000') + racks = ( + Rack(site=site1, location=location1, role=rackrole1, name='Rack 1', u_height=42), + Rack(site=site1, location=location1, role=rackrole1, name='Rack 2', u_height=42) ) + Rack.objects.bulk_create(racks) - # change the created and last_updated of one - Rack.objects.filter(pk=self.rack2.pk).update( + # Change the created and last_updated of the second rack + Rack.objects.filter(pk=racks[1].pk).update( last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)), created=make_aware(datetime.datetime(2001, 2, 3)) ) def test_get_rack_created(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created=2001-02-03'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_created_gte(self): + rack1 = Rack.objects.get(name='Rack 1') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) + self.assertEqual(response.data['results'][0]['id'], rack1.pk) def test_get_rack_created_lte(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_last_updated(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_last_updated_gte(self): + rack1 = Rack.objects.get(name='Rack 1') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) + self.assertEqual(response.data['results'][0]['id'], rack1.pk) def test_get_rack_last_updated_lte(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) class ContentTypeTest(APITestCase): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 2a03e05d6..d890e3ebe 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,10 +1,12 @@ +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse from rest_framework import status from dcim.filtersets import SiteFilterSet -from dcim.forms import SiteCSVForm +from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField @@ -102,6 +104,32 @@ class CustomFieldTest(TestCase): instance.refresh_from_db() self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_decimal_field(self): + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='decimal_field', + type=CustomFieldTypeChoices.TYPE_DECIMAL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (123456.54, 0, -123456.78): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_boolean_field(self): # Create a custom field & check that initial value is null @@ -264,6 +292,7 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_OBJECT, + object_type=ContentType.objects.get_for_model(VLAN), required=False ) cf.content_types.set([self.object_type]) @@ -295,6 +324,7 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + object_type=ContentType.objects.get_for_model(VLAN), required=False ) cf.content_types.set([self.object_type]) @@ -343,7 +373,8 @@ class CustomFieldTest(TestCase): class CustomFieldManagerTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') custom_field.save() @@ -373,7 +404,8 @@ class CustomFieldAPITest(APITestCase): custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), - CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), @@ -424,14 +456,15 @@ class CustomFieldAPITest(APITestCase): custom_fields[0].name: 'bar', custom_fields[1].name: 'DEF', custom_fields[2].name: 456, - custom_fields[3].name: True, - custom_fields[4].name: '2020-01-02', - custom_fields[5].name: 'http://example.com/2', - custom_fields[6].name: '{"foo": 1, "bar": 2}', - custom_fields[7].name: 'Bar', - custom_fields[8].name: ['Bar', 'Baz'], - custom_fields[9].name: vlans[1].pk, - custom_fields[10].name: [vlans[2].pk, vlans[3].pk], + custom_fields[3].name: Decimal('456.78'), + custom_fields[4].name: True, + custom_fields[5].name: '2020-01-02', + custom_fields[6].name: 'http://example.com/2', + custom_fields[7].name: '{"foo": 1, "bar": 2}', + custom_fields[8].name: 'Bar', + custom_fields[9].name: ['Bar', 'Baz'], + custom_fields[10].name: vlans[1].pk, + custom_fields[11].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() @@ -440,6 +473,7 @@ class CustomFieldAPITest(APITestCase): CustomFieldTypeChoices.TYPE_TEXT: 'string', CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', CustomFieldTypeChoices.TYPE_DATE: 'string', CustomFieldTypeChoices.TYPE_URL: 'string', @@ -473,7 +507,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, - 'number_field': None, + 'integer_field': None, + 'decimal_field': None, 'boolean_field': None, 'date_field': None, 'url_field': None, @@ -489,7 +524,7 @@ class CustomFieldAPITest(APITestCase): Validate that custom fields are present and correctly set for an object with values defined. """ site2 = Site.objects.get(name='Site 2') - site2_cfvs = site2.custom_field_data + site2_cfvs = site2.cf url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') @@ -497,17 +532,18 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) - self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) + self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field']) + self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) - self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], - site2_cfvs['multiobject_field'] + [obj.pk for obj in site2_cfvs['multiobject_field']] ) def test_create_single_object_with_defaults(self): @@ -531,9 +567,10 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) - self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) @@ -548,7 +585,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -568,7 +606,8 @@ class CustomFieldAPITest(APITestCase): 'custom_fields': { 'text_field': 'bar', 'longtext_field': 'blah blah blah', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -590,9 +629,10 @@ class CustomFieldAPITest(APITestCase): data_cf = data['custom_fields'] self.assertEqual(response_cf['text_field'], data_cf['text_field']) self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) - self.assertEqual(response_cf['number_field'], data_cf['number_field']) + self.assertEqual(response_cf['integer_field'], data_cf['integer_field']) + self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) - self.assertEqual(response_cf['date_field'], data_cf['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['select_field'], data_cf['select_field']) @@ -607,7 +647,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) @@ -652,9 +693,10 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) - self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) @@ -669,7 +711,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -686,7 +729,8 @@ class CustomFieldAPITest(APITestCase): custom_field_data = { 'text_field': 'bar', 'longtext_field': 'abcdefghij', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -726,9 +770,10 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) + self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field']) + self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) - self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) @@ -743,7 +788,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) @@ -759,11 +805,11 @@ class CustomFieldAPITest(APITestCase): modified. """ site2 = Site.objects.get(name='Site 2') - original_cfvs = {**site2.custom_field_data} + original_cfvs = {**site2.cf} data = { 'custom_fields': { 'text_field': 'ABCD', - 'number_field': 1234, + 'integer_field': 1234, }, } url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) @@ -775,33 +821,38 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) - self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) - self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field']) - self.assertEqual( + self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk) + self.assertListEqual( [obj['id'] for obj in response_cf['multiobject_field']], - original_cfvs['multiobject_field'] + [obj.pk for obj in original_cfvs['multiobject_field']] ) # Validate database data - site2.refresh_from_db() - self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) - self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field']) - self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field']) - self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) - self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) + site2 = Site.objects.get(pk=site2.pk) + self.assertEqual(site2.cf['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.cf['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.cf['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field']) + self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.cf['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.cf['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.cf['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.cf['select_field'], original_cfvs['select_field']) + self.assertEqual(site2.cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(site2.cf['object_field'], original_cfvs['object_field']) + self.assertListEqual( + list(site2.cf['multiobject_field']), + list(original_cfvs['multiobject_field']) + ) def test_specify_related_object_by_attr(self): site1 = Site.objects.get(name='Site 1') @@ -871,20 +922,20 @@ class CustomFieldAPITest(APITestCase): url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - cf_integer = CustomField.objects.get(name='number_field') + cf_integer = CustomField.objects.get(name='integer_field') cf_integer.validation_minimum = 10 cf_integer.validation_maximum = 20 cf_integer.save() - data = {'custom_fields': {'number_field': 9}} + data = {'custom_fields': {'integer_field': 9}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 21}} + data = {'custom_fields': {'integer_field': 21}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 15}} + data = {'custom_fields': {'integer_field': 15}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -923,6 +974,7 @@ class CustomFieldImportTest(TestCase): CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), + CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), @@ -943,23 +995,24 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) + response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'}) self.assertEqual(response.status_code, 200) self.assertEqual(Site.objects.count(), 3) # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 9) + self.assertEqual(len(site1.custom_field_data), 10) self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) + self.assertEqual(site1.custom_field_data['decimal'], 123.45) self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') @@ -969,10 +1022,11 @@ class CustomFieldImportTest(TestCase): # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 9) + self.assertEqual(len(site2.custom_field_data), 10) self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) + self.assertEqual(site2.custom_field_data['decimal'], 456.78) self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') @@ -996,7 +1050,7 @@ class CustomFieldImportTest(TestCase): 'slug': 'site-1', } - form = SiteCSVForm(data=form_data) + form = SiteImportForm(data=form_data) self.assertFalse(form.is_valid()) self.assertIn('cf_text', form.errors) @@ -1010,7 +1064,7 @@ class CustomFieldImportTest(TestCase): 'cf_select': 'Choice X' } - form = SiteCSVForm(data=form_data) + form = SiteImportForm(data=form_data) self.assertFalse(form.is_valid()) self.assertIn('cf_select', form.errors) @@ -1035,7 +1089,7 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Check custom field data on new instance - site.cf['foo'] = 'abc' + site.custom_field_data['foo'] = 'abc' self.assertEqual(site.cf['foo'], 'abc') # Check custom field data from database @@ -1050,12 +1104,12 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Set custom field data - site.cf['foo'] = 'abc' - site.cf['bar'] = 'def' + site.custom_field_data['foo'] = 'abc' + site.custom_field_data['bar'] = 'def' with self.assertRaises(ValidationError): site.clean() - del site.cf['bar'] + del site.custom_field_data['bar'] site.clean() def test_missing_required_field(self): @@ -1069,11 +1123,11 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Set custom field data with a required field omitted - site.cf['foo'] = 'abc' + site.custom_field_data['foo'] = 'abc' with self.assertRaises(ValidationError): site.clean() - site.cf['baz'] = 'def' + site.custom_field_data['baz'] = 'def' site.clean() @@ -1097,53 +1151,78 @@ class CustomFieldModelFilterTest(TestCase): cf.save() cf.content_types.set([obj_type]) + # Decimal filtering + cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf.save() + cf.content_types.set([obj_type]) + # Boolean filtering - cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) + cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf.save() cf.content_types.set([obj_type]) # Exact text filtering - cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf4', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose text filtering - cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf5', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Date filtering - cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) + cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE) cf.save() cf.content_types.set([obj_type]) # Exact URL filtering - cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf7', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose URL filtering - cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf8', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField( + name='cf9', + type=CustomFieldTypeChoices.TYPE_SELECT, + choices=['Foo', 'Bar', 'Baz'] + ) cf.save() cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) + cf = CustomField( + name='cf10', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=['A', 'B', 'C', 'X'] + ) cf.save() cf.content_types.set([obj_type]) # Object filtering cf = CustomField( - name='cf10', + name='cf11', type=CustomFieldTypeChoices.TYPE_OBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1152,7 +1231,7 @@ class CustomFieldModelFilterTest(TestCase): # Multi-object filtering cf = CustomField( - name='cf11', + name='cf12', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1162,42 +1241,45 @@ class CustomFieldModelFilterTest(TestCase): Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, - 'cf2': True, - 'cf3': 'foo', + 'cf2': 100.1, + 'cf3': True, 'cf4': 'foo', - 'cf5': '2016-06-26', - 'cf6': 'http://a.example.com', + 'cf5': 'foo', + 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', - 'cf8': 'Foo', - 'cf9': ['A', 'X'], - 'cf10': manufacturers[0].pk, - 'cf11': [manufacturers[0].pk, manufacturers[3].pk], + 'cf8': 'http://a.example.com', + 'cf9': 'Foo', + 'cf10': ['A', 'X'], + 'cf11': manufacturers[0].pk, + 'cf12': [manufacturers[0].pk, manufacturers[3].pk], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': True, - 'cf3': 'foobar', + 'cf2': 200.2, + 'cf3': True, 'cf4': 'foobar', - 'cf5': '2016-06-27', - 'cf6': 'http://b.example.com', + 'cf5': 'foobar', + 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', - 'cf8': 'Bar', - 'cf9': ['B', 'X'], - 'cf10': manufacturers[1].pk, - 'cf11': [manufacturers[1].pk, manufacturers[3].pk], + 'cf8': 'http://b.example.com', + 'cf9': 'Bar', + 'cf10': ['B', 'X'], + 'cf11': manufacturers[1].pk, + 'cf12': [manufacturers[1].pk, manufacturers[3].pk], }), Site(name='Site 3', slug='site-3', custom_field_data={ 'cf1': 300, - 'cf2': False, - 'cf3': 'bar', + 'cf2': 300.3, + 'cf3': False, 'cf4': 'bar', - 'cf5': '2016-06-28', - 'cf6': 'http://c.example.com', + 'cf5': 'bar', + 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', - 'cf8': 'Baz', - 'cf9': ['C', 'X'], - 'cf10': manufacturers[2].pk, - 'cf11': [manufacturers[2].pk, manufacturers[3].pk], + 'cf8': 'http://c.example.com', + 'cf9': 'Baz', + 'cf10': ['C', 'X'], + 'cf11': manufacturers[2].pk, + 'cf12': [manufacturers[2].pk, manufacturers[3].pk], }), ]) @@ -1209,60 +1291,68 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) + def test_filter_decimal(self): + self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2) + def test_filter_boolean(self): - self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1) def test_filter_text_strict(self): - self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2) def test_filter_text_loose(self): - self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2) def test_filter_date(self): - self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) def test_filter_url_strict(self): - self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) def test_filter_url_loose(self): - self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3) def test_filter_object(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) def test_filter_multiobject(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index ce3b572d1..0fe507b67 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase, override_settings -from circuits.models import Provider +from ipam.models import ASN, RIR from dcim.models import Site from extras.validators import CustomValidator @@ -67,21 +67,25 @@ custom_validator = MyValidator() class CustomValidatorTest(TestCase): - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @classmethod + def setUpTestData(cls): + RIR.objects.create(name='RIR 1', slug='rir-1') + + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_configuration(self): - self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) - validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] + self.assertIn('ipam.asn', settings.CUSTOM_VALIDATORS) + validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] self.assertIsInstance(validator, CustomValidator) - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=1).clean() + ASN(asn=1, rir=RIR.objects.first()).clean() - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [max_validator]}) def test_max(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=65535).clean() + ASN(asn=65535, rir=RIR.objects.first()).clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_min_length(self): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9f9483bbb..3c8899b5e 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): custom_links = ( CustomLink( name='Custom Link 1', - content_type=content_types[0], enabled=True, weight=100, new_window=False, @@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 2', - content_type=content_types[1], enabled=True, weight=200, new_window=False, @@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 3', - content_type=content_types[2], enabled=False, weight=300, new_window=True, @@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Custom Link 1', 'Custom Link 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_weight(self): @@ -221,28 +222,124 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class SavedFilterTestCase(TestCase, BaseFilterSetTests): + queryset = SavedFilter.objects.all() + filterset = SavedFilterFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + slug='saved-filter-1', + user=users[0], + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + slug='saved-filter-2', + user=users[1], + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + slug='saved-filter-3', + user=users[2], + weight=300, + enabled=False, + shared=False, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([content_types[i]]) + + def test_name(self): + params = {'name': ['Saved Filter 1', 'Saved Filter 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['saved-filter-1', 'saved-filter-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_user(self): + users = User.objects.filter(username__startswith='User') + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_shared(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_usable(self): + # Filtering for an anonymous user + params = {'usable': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'usable': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ExportTemplateTestCase(TestCase, BaseFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( - ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), + ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), + ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), + ExportTemplate(name='Export Template 3', template_code='TESTING'), ) ExportTemplate.objects.bulk_create(export_templates) + for i, et in enumerate(export_templates): + et.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_description(self): diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ec50b7dd..35402bda3 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase): cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer.content_types.set([obj_type]) + cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf_integer.content_types.set([obj_type]) + cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf_boolean.content_types.set([obj_type]) diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 4929690e7..0ac63c086 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -21,32 +21,32 @@ class ConfigContextTest(TestCase): It also ensures the various config context querysets are consistent. """ - - def setUp(self): + @classmethod + def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') - self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - self.region = Region.objects.create(name="Region") - self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) - self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) - self.platform = Platform.objects.create(name="Platform") - self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") - self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) - self.tag = Tag.objects.create(name="Tag", slug="tag") - self.tag2 = Tag.objects.create(name="Tag2", slug="tag2") + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + region = Region.objects.create(name='Region') + sitegroup = SiteGroup.objects.create(name='Site Group') + site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) + location = Location.objects.create(name='Location 1', slug='location-1', site=site) + platform = Platform.objects.create(name='Platform') + tenantgroup = TenantGroup.objects.create(name='Tenant Group') + tenant = Tenant.objects.create(name='Tenant', group=tenantgroup) + tag1 = Tag.objects.create(name='Tag', slug='tag') + tag2 = Tag.objects.create(name='Tag2', slug='tag2') - self.device = Device.objects.create( + Device.objects.create( name='Device 1', - device_type=self.devicetype, - device_role=self.devicerole, - site=self.site, - location=self.location + device_type=devicetype, + device_role=devicerole, + site=site, + location=location ) def test_higher_weight_wins(self): - + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=101, @@ -72,10 +72,10 @@ class ConfigContextTest(TestCase): "b": 456, "c": 777 } - self.assertEqual(self.device.get_config_context(), expected_data) + self.assertEqual(device.get_config_context(), expected_data) def test_name_ordering_after_weight(self): - + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=100, @@ -101,13 +101,14 @@ class ConfigContextTest(TestCase): "b": 456, "c": 789 } - self.assertEqual(self.device.get_config_context(), expected_data) + self.assertEqual(device.get_config_context(), expected_data) def test_annotation_same_as_get_for_object(self): """ - This test incorperates features from all of the above tests cases to ensure + This test incorporates features from all of the above tests cases to ensure the annotate_config_context_data() and get_for_object() queryset methods are the same. """ + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=101, @@ -142,10 +143,19 @@ class ConfigContextTest(TestCase): ) ConfigContext.objects.bulk_create([context1, context2, context3, context4]) - annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data() - self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) + annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() + self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): + region = Region.objects.first() + sitegroup = SiteGroup.objects.first() + site = Site.objects.first() + location = Location.objects.first() + platform = Platform.objects.first() + tenantgroup = TenantGroup.objects.first() + tenant = Tenant.objects.first() + tag = Tag.objects.first() + region_context = ConfigContext.objects.create( name="region", weight=100, @@ -153,7 +163,8 @@ class ConfigContextTest(TestCase): "region": 1 } ) - region_context.regions.add(self.region) + region_context.regions.add(region) + sitegroup_context = ConfigContext.objects.create( name="sitegroup", weight=100, @@ -161,7 +172,8 @@ class ConfigContextTest(TestCase): "sitegroup": 1 } ) - sitegroup_context.site_groups.add(self.sitegroup) + sitegroup_context.site_groups.add(sitegroup) + site_context = ConfigContext.objects.create( name="site", weight=100, @@ -169,7 +181,8 @@ class ConfigContextTest(TestCase): "site": 1 } ) - site_context.sites.add(self.site) + site_context.sites.add(site) + location_context = ConfigContext.objects.create( name="location", weight=100, @@ -177,7 +190,8 @@ class ConfigContextTest(TestCase): "location": 1 } ) - location_context.locations.add(self.location) + location_context.locations.add(location) + platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -185,7 +199,8 @@ class ConfigContextTest(TestCase): "platform": 1 } ) - platform_context.platforms.add(self.platform) + platform_context.platforms.add(platform) + tenant_group_context = ConfigContext.objects.create( name="tenant group", weight=100, @@ -193,7 +208,8 @@ class ConfigContextTest(TestCase): "tenant_group": 1 } ) - tenant_group_context.tenant_groups.add(self.tenantgroup) + tenant_group_context.tenant_groups.add(tenantgroup) + tenant_context = ConfigContext.objects.create( name="tenant", weight=100, @@ -201,7 +217,8 @@ class ConfigContextTest(TestCase): "tenant": 1 } ) - tenant_context.tenants.add(self.tenant) + tenant_context.tenants.add(tenant) + tag_context = ConfigContext.objects.create( name="tag", weight=100, @@ -209,23 +226,30 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context.tags.add(self.tag) + tag_context.tags.add(tag) device = Device.objects.create( name="Device 2", - site=self.site, - location=self.location, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + location=location, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) + device.tags.add(tag) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_virtualmachine_relations(self): + region = Region.objects.first() + sitegroup = SiteGroup.objects.first() + site = Site.objects.first() + platform = Platform.objects.first() + tenantgroup = TenantGroup.objects.first() + tenant = Tenant.objects.first() + tag = Tag.objects.first() cluster_type = ClusterType.objects.create(name="Cluster Type") cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) @@ -235,49 +259,49 @@ class ConfigContextTest(TestCase): weight=100, data={"region": 1} ) - region_context.regions.add(self.region) + region_context.regions.add(region) sitegroup_context = ConfigContext.objects.create( name="sitegroup", weight=100, data={"sitegroup": 1} ) - sitegroup_context.site_groups.add(self.sitegroup) + sitegroup_context.site_groups.add(sitegroup) site_context = ConfigContext.objects.create( name="site", weight=100, data={"site": 1} ) - site_context.sites.add(self.site) + site_context.sites.add(site) platform_context = ConfigContext.objects.create( name="platform", weight=100, data={"platform": 1} ) - platform_context.platforms.add(self.platform) + platform_context.platforms.add(platform) tenant_group_context = ConfigContext.objects.create( name="tenant group", weight=100, data={"tenant_group": 1} ) - tenant_group_context.tenant_groups.add(self.tenantgroup) + tenant_group_context.tenant_groups.add(tenantgroup) tenant_context = ConfigContext.objects.create( name="tenant", weight=100, data={"tenant": 1} ) - tenant_context.tenants.add(self.tenant) + tenant_context.tenants.add(tenant) tag_context = ConfigContext.objects.create( name="tag", weight=100, data={"tag": 1} ) - tag_context.tags.add(self.tag) + tag_context.tags.add(tag) cluster_type_context = ConfigContext.objects.create( name="cluster type", @@ -303,11 +327,11 @@ class ConfigContextTest(TestCase): virtual_machine = VirtualMachine.objects.create( name="VM 1", cluster=cluster, - tenant=self.tenant, - platform=self.platform, - role=self.devicerole + tenant=tenant, + platform=platform, + role=DeviceRole.objects.first() ) - virtual_machine.tags.add(self.tag) + virtual_machine.tags.add(tag) annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data() self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context()) @@ -315,12 +339,17 @@ class ConfigContextTest(TestCase): def test_multiple_tags_return_distinct_objects(self): """ Tagged items use a generic relationship, which results in duplicate rows being returned when queried. - This is combatted by by appending distinct() to the config context querysets. This test creates a config + This is combated by appending distinct() to the config context querysets. This test creates a config context assigned to two tags and ensures objects related by those same two tags result in only a single config context record being returned. See https://github.com/netbox-community/netbox/issues/5314 """ + site = Site.objects.first() + platform = Platform.objects.first() + tenant = Tenant.objects.first() + tags = Tag.objects.all() + tag_context = ConfigContext.objects.create( name="tag", weight=100, @@ -328,19 +357,17 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context.tags.add(self.tag) - tag_context.tags.add(self.tag2) + tag_context.tags.set(tags) device = Device.objects.create( name="Device 3", - site=self.site, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) - device.tags.add(self.tag2) + device.tags.set(tags) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1) @@ -357,6 +384,11 @@ class ConfigContextTest(TestCase): See https://github.com/netbox-community/netbox/issues/5387 """ + site = Site.objects.first() + platform = Platform.objects.first() + tenant = Tenant.objects.first() + tag1, tag2 = list(Tag.objects.all()) + tag_context_1 = ConfigContext.objects.create( name="tag-1", weight=100, @@ -364,7 +396,8 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context_1.tags.add(self.tag) + tag_context_1.tags.add(tag1) + tag_context_2 = ConfigContext.objects.create( name="tag-2", weight=100, @@ -372,18 +405,17 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context_2.tags.add(self.tag2) + tag_context_2.tags.add(tag2) device = Device.objects.create( name="Device 3", - site=self.site, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) - device.tags.add(self.tag2) + device.tags.set([tag1, tag2]) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2) diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 299cab9ef..cb7629ad2 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,9 +5,10 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.registry import registry +from extras.plugins import PluginMenu, get_plugin_config from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query +from netbox.registry import registry @skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") @@ -58,9 +59,28 @@ class PluginTest(TestCase): response = client.get(url) self.assertEqual(response.status_code, 200) + def test_registered_views(self): + + # Test URL resolution + url = reverse('dcim:site_extra', kwargs={'pk': 1}) + self.assertEqual(url, '/dcim/sites/1/other-stuff/') + + # Test GET request + client = Client() + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_menu(self): + """ + Check menu registration. + """ + menu = registry['plugins']['menus'][0] + self.assertIsInstance(menu, PluginMenu) + self.assertEqual(menu.label, 'Dummy Plugin') + def test_menu_items(self): """ - Check that plugin MenuItems and MenuButtons are registered. + Check menu_items registration. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) menu_items = registry['plugins']['menu_items']['Dummy plugin'] @@ -153,3 +173,13 @@ class PluginTest(TestCase): self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertTrue(issubclass(Query, DummyQuery)) + + @override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}}) + def test_get_plugin_config(self): + """ + Validate that get_plugin_config() returns config parameters correctly. + """ + plugin = 'extras.tests.dummy_plugin' + self.assertEqual(get_plugin_config(plugin, 'foo'), 123) + self.assertEqual(get_plugin_config(plugin, 'bar'), None) + self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 936213cbf..98de95e8f 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -32,6 +32,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'label': 'Field X', 'type': 'text', 'content_types': [site_ct.pk], + 'search_weight': 2000, 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT, 'default': None, 'weight': 200, @@ -40,11 +41,18 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', - 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write', - 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write', - 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write', - 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write', + 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', + ) + + cls.csv_update_data = ( + 'id,label', + f'{custom_fields[0].pk},New label 1', + f'{custom_fields[1].pk},New label 2', + f'{custom_fields[2].pk},New label 3', ) cls.bulk_edit_data = { @@ -58,17 +66,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) - CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), - )) + custom_links = ( + CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'), + ) + CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) cls.form_data = { 'name': 'Custom Link X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, @@ -77,12 +87,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,enabled,weight,button_class,link_text,link_url", + "name,content_types,enabled,weight,button_class,link_text,link_url", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", ) + cls.csv_update_data = ( + "id,name", + f"{custom_links[0].pk},Custom Link 7", + f"{custom_links[1].pk},Custom Link 8", + f"{custom_links[2].pk},Custom Link 9", + ) + cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, 'enabled': False, @@ -90,33 +107,114 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = SavedFilter + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + slug='saved-filter-1', + user=users[0], + weight=100, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + slug='saved-filter-2', + user=users[1], + weight=200, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + slug='saved-filter-3', + user=users[2], + weight=300, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) + + cls.form_data = { + 'name': 'Saved Filter X', + 'slug': 'saved-filter-x', + 'content_types': [site_ct.pk], + 'description': 'Foo', + 'weight': 1000, + 'enabled': True, + 'shared': True, + 'parameters': '{"foo": 123}', + } + + cls.csv_data = ( + 'name,slug,content_types,weight,enabled,shared,parameters', + 'Saved Filter 4,saved-filter-4,dcim.device,400,True,True,{"foo": "a"}', + 'Saved Filter 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}', + 'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}', + ) + + cls.csv_update_data = ( + "id,name", + f"{saved_filters[0].pk},Saved Filter 7", + f"{saved_filters[1].pk},Saved Filter 8", + f"{saved_filters[2].pk},Saved Filter 9", + ) + + cls.bulk_edit_data = { + 'weight': 999, + } + + class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ExportTemplate @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" - ExportTemplate.objects.bulk_create(( - ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), - )) + + export_templates = ( + ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ) + ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([site_ct]) cls.form_data = { 'name': 'Export Template X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'template_code': TEMPLATE_CODE, } cls.csv_data = ( - "name,content_type,template_code", + "name,content_types,template_code", f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}", ) + cls.csv_update_data = ( + "id,name", + f"{export_templates[0].pk},Export Template 7", + f"{export_templates[1].pk},Export Template 8", + f"{export_templates[2].pk},Export Template 9", + ) + cls.bulk_edit_data = { 'mime_type': 'text/html', 'file_extension': 'html', @@ -159,6 +257,13 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json", ) + cls.csv_update_data = ( + "id,name", + f"{webhooks[0].pk},Webhook 7", + f"{webhooks[1].pk},Webhook 8", + f"{webhooks[2].pk},Webhook 9", + ) + cls.bulk_edit_data = { 'enabled': False, 'type_create': False, @@ -174,11 +279,12 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Tag.objects.bulk_create(( + tags = ( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 3', slug='tag-3'), - )) + ) + Tag.objects.bulk_create(tags) cls.form_data = { 'name': 'Tag X', @@ -194,6 +300,13 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tag 6,tag-6,0000ff,Sixth tag", ) + cls.csv_update_data = ( + "id,name,description", + f"{tags[0].pk},Tag 7,Fourth tag7", + f"{tags[1].pk},Tag 8,Fifth tag8", + f"{tags[2].pk},Tag 9,Sixth tag9", + ) + cls.bulk_edit_data = { 'color': '00ff00', } @@ -326,13 +439,13 @@ class CustomLinkTest(TestCase): def test_view_object_with_custom_link(self): customlink = CustomLink( - content_type=ContentType.objects.get_for_model(Site), name='Test', link_text='FOO {{ obj.name }} BAR', link_url='http://example.com/?site={{ obj.slug }}', new_window=False ) customlink.save() + customlink.content_types.set([ContentType.objects.get_for_model(Site)]) site = Site(name='Test Site', slug='test-site') site.save() diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 111ec6353..19264dabb 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -23,6 +23,7 @@ class WebhookTest(APITestCase): def setUp(self): super().setUp() + # Ensure the queue has been cleared for each test self.queue = django_rq.get_queue('default') self.queue.empty() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ced3bd4b9..f41a45f5a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,7 +1,7 @@ -from django.urls import path, re_path +from django.urls import include, path, re_path -from extras import models, views -from netbox.views.generic import ObjectChangeLogView +from extras import views +from utilities.urls import get_model_urls app_name = 'extras' @@ -13,11 +13,7 @@ urlpatterns = [ path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'), path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'), path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), - path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), - path('custom-fields//edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'), - path('custom-fields//delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'), - path('custom-fields//changelog/', ObjectChangeLogView.as_view(), name='customfield_changelog', - kwargs={'model': models.CustomField}), + path('custom-fields//', include(get_model_urls('extras', 'customfield'))), # Custom links path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), @@ -25,11 +21,7 @@ urlpatterns = [ path('custom-links/import/', views.CustomLinkBulkImportView.as_view(), name='customlink_import'), path('custom-links/edit/', views.CustomLinkBulkEditView.as_view(), name='customlink_bulk_edit'), path('custom-links/delete/', views.CustomLinkBulkDeleteView.as_view(), name='customlink_bulk_delete'), - path('custom-links//', views.CustomLinkView.as_view(), name='customlink'), - path('custom-links//edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'), - path('custom-links//delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'), - path('custom-links//changelog/', ObjectChangeLogView.as_view(), name='customlink_changelog', - kwargs={'model': models.CustomLink}), + path('custom-links//', include(get_model_urls('extras', 'customlink'))), # Export templates path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'), @@ -37,11 +29,15 @@ urlpatterns = [ path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'), path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'), path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), - path('export-templates//', views.ExportTemplateView.as_view(), name='exporttemplate'), - path('export-templates//edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'), - path('export-templates//delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'), - path('export-templates//changelog/', ObjectChangeLogView.as_view(), name='exporttemplate_changelog', - kwargs={'model': models.ExportTemplate}), + path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), + + # Saved filters + path('saved-filters/', views.SavedFilterListView.as_view(), name='savedfilter_list'), + path('saved-filters/add/', views.SavedFilterEditView.as_view(), name='savedfilter_add'), + path('saved-filters/import/', views.SavedFilterBulkImportView.as_view(), name='savedfilter_import'), + path('saved-filters/edit/', views.SavedFilterBulkEditView.as_view(), name='savedfilter_bulk_edit'), + path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), + path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))), # Webhooks path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), @@ -49,11 +45,7 @@ urlpatterns = [ path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'), path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'), path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'), - path('webhooks//', views.WebhookView.as_view(), name='webhook'), - path('webhooks//edit/', views.WebhookEditView.as_view(), name='webhook_edit'), - path('webhooks//delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'), - path('webhooks//changelog/', ObjectChangeLogView.as_view(), name='webhook_changelog', - kwargs={'model': models.Webhook}), + path('webhooks//', include(get_model_urls('extras', 'webhook'))), # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), @@ -61,48 +53,40 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path('tags//', views.TagView.as_view(), name='tag'), - path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', ObjectChangeLogView.as_view(), name='tag_changelog', - kwargs={'model': models.Tag}), + path('tags//', include(get_model_urls('extras', 'tag'))), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), - path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), - path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), - path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), - path('config-contexts//changelog/', ObjectChangeLogView.as_view(), name='configcontext_changelog', - kwargs={'model': models.ConfigContext}), + path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), # Image attachments path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), - path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), - path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + path('image-attachments//', include(get_model_urls('extras', 'imageattachment'))), # Journal entries path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'), path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), - path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), - path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), - path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), - path('journal-entries//changelog/', ObjectChangeLogView.as_view(), name='journalentry_changelog', - kwargs={'model': models.JournalEntry}), + path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path('changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + path('changelog//', include(get_model_urls('extras', 'objectchange'))), # Reports path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/results//', views.ReportResultView.as_view(), name='report_result'), re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'), + # Job results + path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'), + path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'), + path('job-results//delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'), + # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index e16807821..268bf9e80 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -3,7 +3,7 @@ from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from extras.constants import EXTRAS_FEATURES -from extras.registry import registry +from netbox.registry import registry def is_taggable(obj): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d8a015bb0..2d2608ae8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -9,12 +9,12 @@ from django_rq.queues import get_connection from rq import Worker from netbox.views import generic -from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict -from utilities.views import ContentTypePermissionRequiredMixin +from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .choices import JobResultStatusChoices +from .forms.reports import ReportForm from .models import * from .reports import get_report, get_reports, run_report from .scripts import get_scripts, run_script @@ -31,22 +31,25 @@ class CustomFieldListView(generic.ObjectListView): table = tables.CustomFieldTable +@register_model_view(CustomField) class CustomFieldView(generic.ObjectView): queryset = CustomField.objects.all() +@register_model_view(CustomField, 'edit') class CustomFieldEditView(generic.ObjectEditView): queryset = CustomField.objects.all() form = forms.CustomFieldForm +@register_model_view(CustomField, 'delete') class CustomFieldDeleteView(generic.ObjectDeleteView): queryset = CustomField.objects.all() class CustomFieldBulkImportView(generic.BulkImportView): queryset = CustomField.objects.all() - model_form = forms.CustomFieldCSVForm + model_form = forms.CustomFieldImportForm table = tables.CustomFieldTable @@ -74,22 +77,25 @@ class CustomLinkListView(generic.ObjectListView): table = tables.CustomLinkTable +@register_model_view(CustomLink) class CustomLinkView(generic.ObjectView): queryset = CustomLink.objects.all() +@register_model_view(CustomLink, 'edit') class CustomLinkEditView(generic.ObjectEditView): queryset = CustomLink.objects.all() form = forms.CustomLinkForm +@register_model_view(CustomLink, 'delete') class CustomLinkDeleteView(generic.ObjectDeleteView): queryset = CustomLink.objects.all() class CustomLinkBulkImportView(generic.BulkImportView): queryset = CustomLink.objects.all() - model_form = forms.CustomLinkCSVForm + model_form = forms.CustomLinkImportForm table = tables.CustomLinkTable @@ -117,22 +123,25 @@ class ExportTemplateListView(generic.ObjectListView): table = tables.ExportTemplateTable +@register_model_view(ExportTemplate) class ExportTemplateView(generic.ObjectView): queryset = ExportTemplate.objects.all() +@register_model_view(ExportTemplate, 'edit') class ExportTemplateEditView(generic.ObjectEditView): queryset = ExportTemplate.objects.all() form = forms.ExportTemplateForm +@register_model_view(ExportTemplate, 'delete') class ExportTemplateDeleteView(generic.ObjectDeleteView): queryset = ExportTemplate.objects.all() class ExportTemplateBulkImportView(generic.BulkImportView): queryset = ExportTemplate.objects.all() - model_form = forms.ExportTemplateCSVForm + model_form = forms.ExportTemplateImportForm table = tables.ExportTemplateTable @@ -149,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +# +# Saved filters +# + +class SavedFilterMixin: + + def get_queryset(self, request): + """ + Return only shared SavedFilters, or those owned by the current user, unless + this is a superuser. + """ + queryset = SavedFilter.objects.all() + user = request.user + if user.is_superuser: + return queryset + if user.is_anonymous: + return queryset.filter(shared=True) + return queryset.filter( + Q(shared=True) | Q(user=user) + ) + + +class SavedFilterListView(SavedFilterMixin, generic.ObjectListView): + filterset = filtersets.SavedFilterFilterSet + filterset_form = forms.SavedFilterFilterForm + table = tables.SavedFilterTable + + +@register_model_view(SavedFilter) +class SavedFilterView(SavedFilterMixin, generic.ObjectView): + queryset = SavedFilter.objects.all() + + +@register_model_view(SavedFilter, 'edit') +class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): + queryset = SavedFilter.objects.all() + form = forms.SavedFilterForm + + def alter_object(self, obj, request, url_args, url_kwargs): + if not obj.pk: + obj.user = request.user + return obj + + +@register_model_view(SavedFilter, 'delete') +class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): + queryset = SavedFilter.objects.all() + + +class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): + queryset = SavedFilter.objects.all() + model_form = forms.SavedFilterImportForm + table = tables.SavedFilterTable + + +class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + form = forms.SavedFilterBulkEditForm + + +class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + + # # Webhooks # @@ -160,22 +237,25 @@ class WebhookListView(generic.ObjectListView): table = tables.WebhookTable +@register_model_view(Webhook) class WebhookView(generic.ObjectView): queryset = Webhook.objects.all() +@register_model_view(Webhook, 'edit') class WebhookEditView(generic.ObjectEditView): queryset = Webhook.objects.all() form = forms.WebhookForm +@register_model_view(Webhook, 'delete') class WebhookDeleteView(generic.ObjectDeleteView): queryset = Webhook.objects.all() class WebhookBulkImportView(generic.BulkImportView): queryset = Webhook.objects.all() - model_form = forms.WebhookCSVForm + model_form = forms.WebhookImportForm table = tables.WebhookTable @@ -205,6 +285,7 @@ class TagListView(generic.ObjectListView): table = tables.TagTable +@register_model_view(Tag) class TagView(generic.ObjectView): queryset = Tag.objects.all() @@ -230,18 +311,20 @@ class TagView(generic.ObjectView): } +@register_model_view(Tag, 'edit') class TagEditView(generic.ObjectEditView): queryset = Tag.objects.all() form = forms.TagForm +@register_model_view(Tag, 'delete') class TagDeleteView(generic.ObjectDeleteView): queryset = Tag.objects.all() class TagBulkImportView(generic.BulkImportView): queryset = Tag.objects.all() - model_form = forms.TagCSVForm + model_form = forms.TagImportForm table = tables.TagTable @@ -272,6 +355,7 @@ class ConfigContextListView(generic.ObjectListView): actions = ('add', 'bulk_edit', 'bulk_delete') +@register_model_view(ConfigContext) class ConfigContextView(generic.ObjectView): queryset = ConfigContext.objects.all() @@ -309,6 +393,7 @@ class ConfigContextView(generic.ObjectView): } +@register_model_view(ConfigContext, 'edit') class ConfigContextEditView(generic.ObjectEditView): queryset = ConfigContext.objects.all() form = forms.ConfigContextForm @@ -321,6 +406,7 @@ class ConfigContextBulkEditView(generic.BulkEditView): form = forms.ConfigContextBulkEditForm +@register_model_view(ConfigContext, 'delete') class ConfigContextDeleteView(generic.ObjectDeleteView): queryset = ConfigContext.objects.all() @@ -352,7 +438,6 @@ class ObjectConfigContextView(generic.ObjectView): 'source_contexts': source_contexts, 'format': format, 'base_template': self.base_template, - 'active_tab': 'config-context', } @@ -369,6 +454,7 @@ class ObjectChangeListView(generic.ObjectListView): actions = ('export',) +@register_model_view(ObjectChange) class ObjectChangeView(generic.ObjectView): queryset = ObjectChange.objects.all() @@ -426,6 +512,7 @@ class ObjectChangeView(generic.ObjectView): # Image attachments # +@register_model_view(ImageAttachment, 'edit') class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() form = forms.ImageAttachmentForm @@ -448,6 +535,7 @@ class ImageAttachmentEditView(generic.ObjectEditView): } +@register_model_view(ImageAttachment, 'delete') class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() @@ -467,10 +555,12 @@ class JournalEntryListView(generic.ObjectListView): actions = ('export', 'bulk_edit', 'bulk_delete') +@register_model_view(JournalEntry) class JournalEntryView(generic.ObjectView): queryset = JournalEntry.objects.all() +@register_model_view(JournalEntry, 'edit') class JournalEntryEditView(generic.ObjectEditView): queryset = JournalEntry.objects.all() form = forms.JournalEntryForm @@ -488,6 +578,7 @@ class JournalEntryEditView(generic.ObjectEditView): return reverse(viewname, kwargs={'pk': obj.pk}) +@register_model_view(JournalEntry, 'delete') class JournalEntryDeleteView(generic.ObjectDeleteView): queryset = JournalEntry.objects.all() @@ -569,7 +660,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): return render(request, 'extras/report.html', { 'report': report, - 'run_form': ConfirmationForm(), + 'form': ReportForm(), }) def post(self, request, module, name): @@ -582,24 +673,34 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if report is None: raise Http404 - # Allow execution only if RQ worker process is running - if not Worker.count(get_connection('default')): - messages.error(request, "Unable to run report: RQ worker process not running.") - return render(request, 'extras/report.html', { - 'report': report, - }) + form = ReportForm(request.POST) - # Run the Report. A new JobResult is created. - report_content_type = ContentType.objects.get(app_label='extras', model='report') - job_result = JobResult.enqueue_job( - run_report, - report.full_name, - report_content_type, - request.user, - job_timeout=report.job_timeout - ) + if form.is_valid(): - return redirect('extras:report_result', job_result_pk=job_result.pk) + # Allow execution only if RQ worker process is running + if not Worker.count(get_connection('default')): + messages.error(request, "Unable to run report: RQ worker process not running.") + return render(request, 'extras/report.html', { + 'report': report, + }) + + # Run the Report. A new JobResult is created. + job_result = JobResult.enqueue_job( + run_report, + name=report.full_name, + obj_type=ContentType.objects.get_for_model(Report), + user=request.user, + schedule_at=form.cleaned_data.get('schedule_at'), + interval=form.cleaned_data.get('interval'), + job_timeout=report.job_timeout + ) + + return redirect('extras:report_result', job_result_pk=job_result.pk) + + return render(request, 'extras/report.html', { + 'report': report, + 'form': form, + }) class ReportResultView(ContentTypePermissionRequiredMixin, View): @@ -624,7 +725,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): 'report': report, 'result': result, }) - if result.completed: + if result.completed or not result.started: response.status_code = 286 return response @@ -685,9 +786,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): form = script.as_form(initial=normalize_querydict(request.GET)) # Look for a pending JobResult (use the latest one by creation timestamp) - script_content_type = ContentType.objects.get(app_label='extras', model='script') script.result = JobResult.objects.filter( - obj_type=script_content_type, + obj_type=ContentType.objects.get_for_model(Script), name=script.full_name, ).exclude( status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES @@ -713,19 +813,17 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): messages.error(request, "Unable to run script: RQ worker process not running.") elif form.is_valid(): - commit = form.cleaned_data.pop('_commit') - - script_content_type = ContentType.objects.get(app_label='extras', model='script') - job_result = JobResult.enqueue_job( run_script, - script.full_name, - script_content_type, - request.user, + name=script.full_name, + obj_type=ContentType.objects.get_for_model(Script), + user=request.user, + schedule_at=form.cleaned_data.pop('_schedule_at'), + interval=form.cleaned_data.pop('_interval'), data=form.cleaned_data, request=copy_safe_request(request), - commit=commit, job_timeout=script.job_timeout, + commit=form.cleaned_data.pop('_commit') ) return redirect('extras:script_result', job_result_pk=job_result.pk) @@ -756,7 +854,7 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View) 'script': script, 'result': result, }) - if result.completed: + if result.completed or not result.started: response.status_code = 286 return response @@ -765,3 +863,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View) 'result': result, 'class_name': script.__class__.__name__ }) + + +# +# Job results +# + +class JobResultListView(generic.ObjectListView): + queryset = JobResult.objects.all() + filterset = filtersets.JobResultFilterSet + filterset_form = forms.JobResultFilterForm + table = tables.JobResultTable + actions = ('export', 'delete', 'bulk_delete', ) + + +class JobResultDeleteView(generic.ObjectDeleteView): + queryset = JobResult.objects.all() + + +class JobResultBulkDeleteView(generic.BulkDeleteView): + queryset = JobResult.objects.all() + filterset = filtersets.JobResultFilterSet + table = tables.JobResultTable diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index bef90a245..23702949a 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -5,11 +5,13 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django_rq import get_queue +from netbox.config import get_config +from netbox.constants import RQ_QUEUE_DEFAULT +from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.utils import serialize_object from .choices import * from .models import Webhook -from .registry import registry def serialize_for_webhook(instance): @@ -78,7 +80,8 @@ def flush_webhooks(queue): """ Flush a list of object representation to RQ for webhook processing. """ - rq_queue = get_queue('default') + rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT) + rq_queue = get_queue(rq_queue_name) webhooks_cache = { 'type_create': {}, 'type_update': {}, diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index eff39a418..6ec062aee 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -31,8 +31,8 @@ class ASNSerializer(NetBoxModelSerializer): class Meta: model = ASN fields = [ - 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'site_count', 'provider_count', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'site_count', 'provider_count', ] @@ -61,8 +61,9 @@ class VRFSerializer(NetBoxModelSerializer): class Meta: model = VRF fields = [ - 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', - 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', + 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', + 'prefix_count', ] @@ -77,7 +78,8 @@ class RouteTargetSerializer(NetBoxModelSerializer): class Meta: model = RouteTarget fields = [ - 'id', 'url', 'display', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] @@ -106,8 +108,8 @@ class AggregateSerializer(NetBoxModelSerializer): class Meta: model = Aggregate fields = [ - 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -123,8 +125,8 @@ class FHRPGroupSerializer(NetBoxModelSerializer): class Meta: model = FHRPGroup fields = [ - 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', ] @@ -215,7 +217,7 @@ class VLANSerializer(NetBoxModelSerializer): model = VLAN fields = [ 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', - 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] @@ -273,7 +275,8 @@ class PrefixSerializer(NetBoxModelSerializer): model = Prefix fields = [ 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', - 'mark_utilized', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + '_depth', ] read_only_fields = ['family'] @@ -342,7 +345,7 @@ class IPRangeSerializer(NetBoxModelSerializer): model = IPRange fields = [ 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', ] read_only_fields = ['family'] @@ -371,8 +374,8 @@ class IPAddressSerializer(NetBoxModelSerializer): model = IPAddress fields = [ 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', - 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.JSONField) @@ -415,8 +418,8 @@ class ServiceTemplateSerializer(NetBoxModelSerializer): class Meta: model = ServiceTemplate fields = [ - 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -436,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): model = Service fields = [ 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] # @@ -465,7 +468,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index 413c8c1bc..4b0820fef 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -6,4 +6,4 @@ class IPAMConfig(AppConfig): verbose_name = "IPAM" def ready(self): - import ipam.signals + from . import signals, search diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 2634dd725..d5d589dc6 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -3,6 +3,7 @@ import netaddr from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q +from django.utils.translation import gettext as _ from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -41,24 +42,24 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), - label='Import target', + label=_('Import target'), ) import_target = django_filters.ModelMultipleChoiceFilter( field_name='import_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Import target (name)', + label=_('Import target (name)'), ) export_target_id = django_filters.ModelMultipleChoiceFilter( field_name='export_targets', queryset=RouteTarget.objects.all(), - label='Export target', + label=_('Export target'), ) export_target = django_filters.ModelMultipleChoiceFilter( field_name='export_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Export target (name)', + label=_('Export target (name)'), ) def search(self, queryset, name, value): @@ -79,24 +80,24 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): importing_vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='importing_vrfs', queryset=VRF.objects.all(), - label='Importing VRF', + label=_('Importing VRF'), ) importing_vrf = django_filters.ModelMultipleChoiceFilter( field_name='importing_vrfs__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='Import VRF (RD)', + label=_('Import VRF (RD)'), ) exporting_vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='exporting_vrfs', queryset=VRF.objects.all(), - label='Exporting VRF', + label=_('Exporting VRF'), ) exporting_vrf = django_filters.ModelMultipleChoiceFilter( field_name='exporting_vrfs__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='Export VRF (RD)', + label=_('Export VRF (RD)'), ) def search(self, queryset, name, value): @@ -126,17 +127,17 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) prefix = django_filters.CharFilter( method='filter_prefix', - label='Prefix', + label=_('Prefix'), ) rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), - label='RIR (ID)', + label=_('RIR (ID)'), ) rir = django_filters.ModelMultipleChoiceFilter( field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', - label='RIR (slug)', + label=_('RIR (slug)'), ) class Meta: @@ -169,24 +170,24 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), - label='RIR (ID)', + label=_('RIR (ID)'), ) rir = django_filters.ModelMultipleChoiceFilter( field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', - label='RIR (slug)', + label=_('RIR (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) class Meta: @@ -218,19 +219,19 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) prefix = MultiValueCharFilter( method='filter_prefix', - label='Prefix', + label=_('Prefix'), ) within = django_filters.CharFilter( method='search_within', - label='Within prefix', + label=_('Within prefix'), ) within_include = django_filters.CharFilter( method='search_within_include', - label='Within and including prefix', + label=_('Within and including prefix'), ) contains = django_filters.CharFilter( method='search_contains', - label='Prefixes which contain this prefix or IP', + label=_('Prefixes which contain this prefix or IP'), ) depth = MultiValueNumberFilter( field_name='_depth' @@ -252,78 +253,78 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) present_in_vrf_id = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', - label='VRF' + label=_('VRF') ) present_in_vrf = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), - label='VLAN (ID)', + label=_('VLAN (ID)'), ) vlan_vid = django_filters.NumberFilter( field_name='vlan__vid', - label='VLAN number (1-4094)', + label=_('VLAN number (1-4094)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=PrefixStatusChoices, @@ -406,27 +407,27 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): ) contains = django_filters.CharFilter( method='search_contains', - label='Ranges which contain this prefix or IP', + label=_('Ranges which contain this prefix or IP'), ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=IPRangeStatusChoices, @@ -468,87 +469,87 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) parent = MultiValueCharFilter( method='search_by_parent', - label='Parent prefix', + label=_('Parent prefix'), ) address = MultiValueCharFilter( method='filter_address', - label='Address', + label=_('Address'), ) mask_length = django_filters.NumberFilter( method='filter_mask_length', - label='Mask length', + label=_('Mask length'), ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) present_in_vrf_id = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', - label='VRF' + label=_('VRF') ) present_in_vrf = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = MultiValueCharFilter( method='filter_virtual_machine', field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = MultiValueNumberFilter( method='filter_virtual_machine', field_name='pk', - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.all(), to_field_name='name', - label='Interface (name)', + label=_('Interface (name)'), ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), - label='Interface (ID)', + label=_('Interface (ID)'), ) vminterface = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__name', queryset=VMInterface.objects.all(), to_field_name='name', - label='VM interface (name)', + label=_('VM interface (name)'), ) vminterface_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface', queryset=VMInterface.objects.all(), - label='VM interface (ID)', + label=_('VM interface (ID)'), ) fhrpgroup_id = django_filters.ModelMultipleChoiceFilter( field_name='fhrpgroup', queryset=FHRPGroup.objects.all(), - label='FHRP group (ID)', + label=_('FHRP group (ID)'), ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', - label='Is assigned to an interface', + label=_('Is assigned to an interface'), ) status = django_filters.MultipleChoiceFilter( choices=IPAddressStatusChoices, @@ -653,13 +654,14 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): class Meta: model = FHRPGroup - fields = ['id', 'group_id', 'auth_key'] + fields = ['id', 'group_id', 'name', 'auth_key'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(description__icontains=value) + Q(description__icontains=value) | + Q(name__icontains=value) ) def filter_related_ip(self, queryset, name, value): @@ -687,27 +689,27 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): interface_type = ContentTypeFilter() group_id = django_filters.ModelMultipleChoiceFilter( queryset=FHRPGroup.objects.all(), - label='Group (ID)', + label=_('Group (ID)'), ) device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = MultiValueCharFilter( method='filter_virtual_machine', field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = MultiValueNumberFilter( method='filter_virtual_machine', field_name='pk', - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) class Meta: @@ -786,57 +788,57 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) group_id = django_filters.ModelMultipleChoiceFilter( queryset=VLANGroup.objects.all(), - label='Group (ID)', + label=_('Group (ID)'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='group__slug', queryset=VLANGroup.objects.all(), to_field_name='slug', - label='Group', + label=_('Group'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=VLANStatusChoices, @@ -892,23 +894,23 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet): class ServiceFilterSet(NetBoxModelFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( queryset=VirtualMachine.objects.all(), - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) port = NumericArrayFilter( field_name='ports', @@ -938,24 +940,24 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), - label='Import target', + label=_('Import target'), ) import_target = django_filters.ModelMultipleChoiceFilter( field_name='import_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Import target (name)', + label=_('Import target (name)'), ) export_target_id = django_filters.ModelMultipleChoiceFilter( field_name='export_targets', queryset=RouteTarget.objects.all(), - label='Export target', + label=_('Export target'), ) export_target = django_filters.ModelMultipleChoiceFilter( field_name='export_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Export target (name)', + label=_('Export target (name)'), ) class Meta: @@ -976,92 +978,92 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class L2VPNTerminationFilterSet(NetBoxModelFilterSet): l2vpn_id = django_filters.ModelMultipleChoiceFilter( queryset=L2VPN.objects.all(), - label='L2VPN (ID)', + label=_('L2VPN (ID)'), ) l2vpn = django_filters.ModelMultipleChoiceFilter( field_name='l2vpn__slug', queryset=L2VPN.objects.all(), to_field_name='slug', - label='L2VPN (slug)', + label=_('L2VPN (slug)'), ) region = MultiValueCharFilter( method='filter_region', field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) region_id = MultiValueNumberFilter( method='filter_region', field_name='pk', - label='Region (ID)', + label=_('Region (ID)'), ) site = MultiValueCharFilter( method='filter_site', field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) site_id = MultiValueNumberFilter( method='filter_site', field_name='pk', - label='Site (ID)', + label=_('Site (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='interface__device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = django_filters.ModelMultipleChoiceFilter( field_name='interface__device', queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__virtual_machine', queryset=VirtualMachine.objects.all(), - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.all(), to_field_name='name', - label='Interface (name)', + label=_('Interface (name)'), ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), - label='Interface (ID)', + label=_('Interface (ID)'), ) vminterface = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__name', queryset=VMInterface.objects.all(), to_field_name='name', - label='VM interface (name)', + label=_('VM interface (name)'), ) vminterface_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface', queryset=VMInterface.objects.all(), - label='VM Interface (ID)', + label=_('VM Interface (ID)'), ) vlan = django_filters.ModelMultipleChoiceFilter( field_name='vlan__name', queryset=VLAN.objects.all(), to_field_name='name', - label='VLAN (name)', + label=_('VLAN (name)'), ) vlan_vid = django_filters.NumberFilter( field_name='vlan__vid', - label='VLAN number (1-4094)', + label=_('VLAN number (1-4094)'), ) vlan_id = django_filters.ModelMultipleChoiceFilter( field_name='vlan', queryset=VLAN.objects.all(), - label='VLAN (ID)', + label=_('VLAN (ID)'), ) assigned_object_type = ContentTypeFilter() diff --git a/netbox/ipam/forms/__init__.py b/netbox/ipam/forms/__init__.py index fc3352358..ba97d6dfa 100644 --- a/netbox/ipam/forms/__init__.py +++ b/netbox/ipam/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_create import * from .bulk_edit import * diff --git a/netbox/ipam/forms/bulk_create.py b/netbox/ipam/forms/bulk_create.py index 790474c6e..6d07951a3 100644 --- a/netbox/ipam/forms/bulk_create.py +++ b/netbox/ipam/forms/bulk_create.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from utilities.forms import BootstrapMixin, ExpandableIPAddressField @@ -9,5 +10,5 @@ __all__ = ( class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): pattern = ExpandableIPAddressField( - label='Address pattern' + label=_('Address pattern') ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 5f579b07f..d0af43975 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.models import Region, Site, SiteGroup from ipam.choices import * @@ -8,8 +9,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, DynamicModelChoiceField, NumericArrayField, StaticSelect, - DynamicModelMultipleChoiceField, + add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, + SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( @@ -40,18 +41,22 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): enforce_unique = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Enforce unique space' + label=_('Enforce unique space') ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VRF fieldsets = ( (None, ('tenant', 'enforce_unique', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): @@ -63,12 +68,16 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RouteTarget fieldsets = ( (None, ('tenant', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RIRBulkEditForm(NetBoxModelBulkEditForm): @@ -96,29 +105,33 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, - label='RIR' + label=_('RIR') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ASN fieldsets = ( (None, ('sites', 'rir', 'tenant', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class AggregateBulkEditForm(NetBoxModelBulkEditForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, - label='RIR' + label=_('RIR') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -128,15 +141,19 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Aggregate fieldsets = ( (None, ('rir', 'tenant', 'date_added', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class RoleBulkEditForm(NetBoxModelBulkEditForm): @@ -175,7 +192,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) prefix_length = forms.IntegerField( min_value=PREFIX_LENGTH_MIN, @@ -198,17 +215,21 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): is_pool = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Is a pool' + label=_('Is a pool') ) mark_utilized = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Treat as 100% utilized' + label=_('Treat as 100% utilized') ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Prefix fieldsets = ( @@ -217,7 +238,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), ) nullable_fields = ( - 'site', 'vrf', 'tenant', 'role', 'description', + 'site', 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -225,7 +246,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -241,16 +262,20 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPRange fieldsets = ( (None, ('status', 'role', 'vrf', 'tenant', 'description')), ) nullable_fields = ( - 'vrf', 'tenant', 'role', 'description', + 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -258,7 +283,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) mask_length = forms.IntegerField( min_value=IPADDRESS_MASK_LENGTH_MIN, @@ -282,12 +307,16 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): dns_name = forms.CharField( max_length=255, required=False, - label='DNS name' + label=_('DNS name') ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPAddress fieldsets = ( @@ -295,7 +324,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'mask_length', 'dns_name')), ) nullable_fields = ( - 'vrf', 'role', 'tenant', 'dns_name', 'description', + 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', ) @@ -308,30 +337,38 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): group_id = forms.IntegerField( min_value=0, required=False, - label='Group ID' + label=_('Group ID') ) auth_type = forms.ChoiceField( choices=add_blank_choice(FHRPGroupAuthTypeChoices), required=False, widget=StaticSelect(), - label='Authentication type' + label=_('Authentication type') ) auth_key = forms.CharField( max_length=255, required=False, - label='Authentication key' + label=_('Authentication key') + ) + name = forms.CharField( + max_length=100, + required=False ) description = forms.CharField( max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = FHRPGroup fieldsets = ( - (None, ('protocol', 'group_id', 'description')), + (None, ('protocol', 'group_id', 'name', 'description')), ('Authentication', ('auth_type', 'auth_key')), ) - nullable_fields = ('auth_type', 'auth_key', 'description') + nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -343,13 +380,13 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, required=False, - label='Minimum child VLAN VID' + label=_('Minimum child VLAN VID') ) max_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, required=False, - label='Maximum child VLAN VID' + label=_('Maximum child VLAN VID') ) description = forms.CharField( max_length=200, @@ -401,9 +438,13 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VLAN fieldsets = ( @@ -411,7 +452,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ('Site & Group', ('region', 'site_group', 'site', 'group')), ) nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', + 'site', 'group', 'tenant', 'role', 'description', 'comments', ) @@ -429,15 +470,19 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ServiceTemplate fieldsets = ( (None, ('protocol', 'ports', 'description')), ) - nullable_fields = ('description',) + nullable_fields = ('description', 'comments') class ServiceBulkEditForm(ServiceTemplateBulkEditForm): @@ -455,15 +500,19 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = L2VPN fieldsets = ( - (None, ('type', 'description', 'tenant')), + (None, ('type', 'tenant', 'description')), ) - nullable_fields = ('tenant', 'description',) + nullable_fields = ('tenant', 'description', 'comments') class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 880d2722f..972b98db2 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,165 +1,165 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Site from ipam.choices import * from ipam.constants import * from ipam.models import * -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface __all__ = ( - 'AggregateCSVForm', - 'ASNCSVForm', - 'FHRPGroupCSVForm', - 'IPAddressCSVForm', - 'IPRangeCSVForm', - 'L2VPNCSVForm', - 'L2VPNTerminationCSVForm', - 'PrefixCSVForm', - 'RIRCSVForm', - 'RoleCSVForm', - 'RouteTargetCSVForm', - 'ServiceCSVForm', - 'ServiceTemplateCSVForm', - 'VLANCSVForm', - 'VLANGroupCSVForm', - 'VRFCSVForm', + 'AggregateImportForm', + 'ASNImportForm', + 'FHRPGroupImportForm', + 'IPAddressImportForm', + 'IPRangeImportForm', + 'L2VPNImportForm', + 'L2VPNTerminationImportForm', + 'PrefixImportForm', + 'RIRImportForm', + 'RoleImportForm', + 'RouteTargetImportForm', + 'ServiceImportForm', + 'ServiceTemplateImportForm', + 'VLANImportForm', + 'VLANGroupImportForm', + 'VRFImportForm', ) -class VRFCSVForm(NetBoxModelCSVForm): +class VRFImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') -class RouteTargetCSVForm(NetBoxModelCSVForm): +class RouteTargetImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = RouteTarget - fields = ('name', 'description', 'tenant') + fields = ('name', 'tenant', 'description', 'comments', 'tags') -class RIRCSVForm(NetBoxModelCSVForm): +class RIRImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = RIR - fields = ('name', 'slug', 'is_private', 'description') + fields = ('name', 'slug', 'is_private', 'description', 'tags') help_texts = { - 'name': 'RIR name', + 'name': _('RIR name'), } -class AggregateCSVForm(NetBoxModelCSVForm): +class AggregateImportForm(NetBoxModelImportForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', - help_text='Assigned RIR' + help_text=_('Assigned RIR') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') -class ASNCSVForm(NetBoxModelCSVForm): +class ASNImportForm(NetBoxModelImportForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', - help_text='Assigned RIR' + help_text=_('Assigned RIR') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description') - help_texts = {} + fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags') -class RoleCSVForm(NetBoxModelCSVForm): +class RoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: model = Role - fields = ('name', 'slug', 'weight', 'description') + fields = ('name', 'slug', 'weight', 'description', 'tags') -class PrefixCSVForm(NetBoxModelCSVForm): +class PrefixImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) vlan_group = CSVModelChoiceField( queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - help_text="VLAN's group (if any)" + help_text=_("VLAN's group (if any)") ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, to_field_name='vid', - help_text="Assigned VLAN" + help_text=_("Assigned VLAN") ) status = CSVChoiceField( choices=PrefixStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: model = Prefix fields = ( 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -177,79 +177,79 @@ class PrefixCSVForm(NetBoxModelCSVForm): self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) -class IPRangeCSVForm(NetBoxModelCSVForm): +class IPRangeImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=IPRangeStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: model = IPRange fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', 'tags', ) -class IPAddressCSVForm(NetBoxModelCSVForm): +class IPAddressImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=IPAddressStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVChoiceField( choices=IPAddressRoleChoices, required=False, - help_text='Functional role' + help_text=_('Functional role') ) device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Parent device of assigned interface (if any)' + help_text=_('Parent device of assigned interface (if any)') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Parent VM of assigned interface (if any)' + help_text=_('Parent VM of assigned interface (if any)') ) interface = CSVModelChoiceField( queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', - help_text='Assigned interface' + help_text=_('Assigned interface') ) is_primary = forms.BooleanField( - help_text='Make this the primary IP for the assigned device', + help_text=_('Make this the primary IP for the assigned device'), required=False ) @@ -257,7 +257,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', + 'dns_name', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -298,13 +298,13 @@ class IPAddressCSVForm(NetBoxModelCSVForm): def save(self, *args, **kwargs): # Set interface assignment - if self.cleaned_data['interface']: + if self.cleaned_data.get('interface'): self.instance.assigned_object = self.cleaned_data['interface'] ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM - if self.cleaned_data['is_primary']: + if self.cleaned_data.get('is_primary'): parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] if self.instance.address.version == 4: parent.primary_ip4 = ipaddress @@ -315,7 +315,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): return ipaddress -class FHRPGroupCSVForm(NetBoxModelCSVForm): +class FHRPGroupImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( choices=FHRPGroupProtocolChoices ) @@ -326,15 +326,15 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments', 'tags') -class VLANGroupCSVForm(NetBoxModelCSVForm): +class VLANGroupImportForm(NetBoxModelImportForm): slug = SlugField() scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - label='Scope type (app & model)' + label=_('Scope type (app & model)') ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, @@ -351,86 +351,86 @@ class VLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags') labels = { 'scope_id': 'Scope ID', } -class VLANCSVForm(NetBoxModelCSVForm): +class VLANImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) group = CSVModelChoiceField( queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned VLAN group' + help_text=_('Assigned VLAN group') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=VLANStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description') + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') help_texts = { 'vid': 'Numeric VLAN ID (1-4094)', 'name': 'VLAN name', } -class ServiceTemplateCSVForm(NetBoxModelCSVForm): +class ServiceTemplateImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( choices=ServiceProtocolChoices, - help_text='IP protocol' + help_text=_('IP protocol') ) class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') -class ServiceCSVForm(NetBoxModelCSVForm): +class ServiceImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Required if not assigned to a VM' + help_text=_('Required if not assigned to a VM') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Required if not assigned to a device' + help_text=_('Required if not assigned to a device') ) protocol = CSVChoiceField( choices=ServiceProtocolChoices, - help_text='IP protocol' + help_text=_('IP protocol') ) class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') -class L2VPNCSVForm(NetBoxModelCSVForm): +class L2VPNImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -438,49 +438,49 @@ class L2VPNCSVForm(NetBoxModelCSVForm): ) type = CSVChoiceField( choices=L2VPNTypeChoices, - help_text='L2VPN type' + help_text=_('L2VPN type') ) class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') -class L2VPNTerminationCSVForm(NetBoxModelCSVForm): +class L2VPNTerminationImportForm(NetBoxModelImportForm): l2vpn = CSVModelChoiceField( queryset=L2VPN.objects.all(), required=True, to_field_name='name', - label='L2VPN', + label=_('L2VPN'), ) device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Parent device (for interface)' + help_text=_('Parent device (for interface)') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Parent virtual machine (for interface)' + help_text=_('Parent virtual machine (for interface)') ) interface = CSVModelChoiceField( queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', - help_text='Assigned interface (device or VM)' + help_text=_('Assigned interface (device or VM)') ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, to_field_name='name', - help_text='Assigned VLAN' + help_text=_('Assigned VLAN') ) class Meta: model = L2VPNTermination - fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan') + fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 34b811e94..fe9d42550 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device @@ -11,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine @@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Route Targets', ('import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('VRF', ('importing_vrf_id', 'exporting_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('family', 'rir_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Assignment', ('rir_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), @@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -334,10 +333,13 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('protocol', 'group_id')), + (None, ('q', 'filter_id', 'tag')), + ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) + name = forms.CharField( + required=False + ) protocol = MultipleChoiceField( choices=FHRPGroupProtocolChoices, required=False @@ -361,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('VLAN ID', ('min_vid', 'max_vid')), ) @@ -395,13 +397,13 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, - label='Minimum VID' + label=_('Minimum VID') ) max_vid = forms.IntegerField( required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, - label='Maximum VID' + label=_('Maximum VID') ) tag = TagFilterField(model) @@ -409,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -462,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( @@ -484,7 +486,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -509,8 +511,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn_id', )), - ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), + (None, ('filter_id', 'l2vpn_id',)), + ('Assigned Object', ( + 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', + )), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/model_forms.py similarity index 87% rename from netbox/ipam/forms/models.py rename to netbox/ipam/forms/model_forms.py index 43fd12cd2..430a4b2f8 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/model_forms.py @@ -1,19 +1,18 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup -from extras.models import Tag from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from tenancy.models import Tenant from utilities.exceptions import PermissionsViolation from utilities.forms import ( - add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -51,6 +50,7 @@ class VRFForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), @@ -61,14 +61,14 @@ class VRFForm(TenancyForm, NetBoxModelForm): class Meta: model = VRF fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', - 'tags', + 'name', 'rd', 'enforce_unique', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] labels = { 'rd': "RD", } help_texts = { - 'rd': "Route distinguisher in any format", + 'rd': _("Route distinguisher in any format"), } @@ -77,11 +77,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm): ('Route Target', ('name', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) + comments = CommentField() class Meta: model = RouteTarget fields = [ - 'name', 'description', 'tenant_group', 'tenant', 'tags', + 'name', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] @@ -104,8 +105,9 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label='RIR' + label=_('RIR') ) + comments = CommentField() fieldsets = ( ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), @@ -115,11 +117,11 @@ class AggregateForm(TenancyForm, NetBoxModelForm): class Meta: model = Aggregate fields = [ - 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', + 'prefix', 'rir', 'date_added', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] help_texts = { - 'prefix': "IPv4 or IPv6 network", - 'rir': "Regional Internet Registry responsible for this prefix", + 'prefix': _("IPv4 or IPv6 network"), + 'rir': _("Regional Internet Registry responsible for this prefix"), } widgets = { 'date_added': DatePicker(), @@ -129,13 +131,14 @@ class AggregateForm(TenancyForm, NetBoxModelForm): class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label='RIR', + label=_('RIR'), ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - label='Sites', + label=_('Sites'), required=False ) + comments = CommentField() fieldsets = ( ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), @@ -145,11 +148,11 @@ class ASNForm(TenancyForm, NetBoxModelForm): class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'comments', 'tags' ] help_texts = { - 'asn': "AS number", - 'rir': "Regional Internet Registry responsible for this prefix", + 'asn': _("AS number"), + 'rir': _("Regional Internet Registry responsible for this prefix"), } widgets = { 'date_added': DatePicker(), @@ -187,7 +190,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -215,7 +218,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group', + label=_('VLAN group'), null_option='None', query_params={ 'site': '$site' @@ -227,7 +230,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN', + label=_('VLAN'), query_params={ 'site_id': '$site', 'group_id': '$vlan_group', @@ -237,6 +240,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), @@ -247,8 +251,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', - 'tenant_group', 'tenant', 'tags', + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', + 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -259,12 +263,13 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), @@ -274,7 +279,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): class Meta: model = IPRange fields = [ - 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -306,7 +312,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Interface', + label=_('Interface'), query_params={ 'virtual_machine_id': '$virtual_machine' } @@ -314,17 +320,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): fhrpgroup = DynamicModelChoiceField( queryset=FHRPGroup.objects.all(), required=False, - label='FHRP Group' + label=_('FHRP Group') ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) nat_region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, - label='Region', + label=_('Region'), initial_params={ 'sites': '$nat_site' } @@ -332,7 +338,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_site_group = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False, - label='Site group', + label=_('Site group'), initial_params={ 'sites': '$nat_site' } @@ -340,7 +346,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', + label=_('Site'), query_params={ 'region_id': '$nat_region', 'group_id': '$nat_site_group', @@ -349,7 +355,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', + label=_('Rack'), null_option='None', query_params={ 'site_id': '$site' @@ -358,7 +364,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - label='Device', + label=_('Device'), query_params={ 'site_id': '$site', 'rack_id': '$nat_rack', @@ -367,12 +373,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster' + label=_('Cluster') ) nat_virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, - label='Virtual Machine', + label=_('Virtual Machine'), query_params={ 'cluster_id': '$nat_cluster', } @@ -380,12 +386,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), required=False, - label='IP Address', + label=_('IP Address'), query_params={ 'device_id': '$nat_device', 'virtual_machine_id': '$nat_virtual_machine', @@ -394,15 +400,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ) primary_for_parent = forms.BooleanField( required=False, - label='Make this the primary IP for the device/VM' + label=_('Make this the primary IP for the device/VM') ) + comments = CommentField() class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', - 'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', - 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device', + 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -495,7 +502,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) class Meta: @@ -513,11 +520,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) q = forms.CharField( required=False, - label='Search', + label=_('Search'), ) @@ -527,20 +534,21 @@ class FHRPGroupForm(NetBoxModelForm): ip_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) ip_address = IPNetworkFormField( required=False, - label='Address' + label=_('Address') ) ip_status = forms.ChoiceField( choices=add_blank_choice(IPAddressStatusChoices), required=False, - label='Status' + label=_('Status') ) + comments = CommentField() fieldsets = ( - ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')), ('Authentication', ('auth_type', 'auth_key')), ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) ) @@ -548,7 +556,8 @@ class FHRPGroupForm(NetBoxModelForm): class Meta: model = FHRPGroup fields = ( - 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description', + 'comments', 'tags', ) widgets = { 'protocol': StaticSelect(), @@ -631,7 +640,7 @@ class VLANGroupForm(NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Site group' + label=_('Site group') ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -668,7 +677,7 @@ class VLANGroupForm(NetBoxModelForm): initial_params={ 'clusters': '$cluster' }, - label='Cluster group' + label=_('Cluster group') ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -732,7 +741,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): ), required=False, widget=StaticSelect, - label='Group scope' + label=_('Group scope') ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -740,7 +749,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): query_params={ 'scope_type': '$scope_type', }, - label='VLAN Group' + label=_('VLAN Group') ) # Site assignment fields @@ -750,7 +759,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Region' + label=_('Region') ) sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), @@ -758,7 +767,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Site group' + label=_('Site group') ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -775,19 +784,21 @@ class VLANForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() class Meta: model = VLAN fields = [ - 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', + 'tags', ] help_texts = { - 'site': "Leave blank if this VLAN spans multiple sites", - 'group': "VLAN group (optional)", - 'vid': "Configured VLAN ID", - 'name': "Configured VLAN name", - 'status': "Operational status of this VLAN", - 'role': "The primary function of this VLAN", + 'site': _("Leave blank if this VLAN spans multiple sites"), + 'group': _("VLAN group (optional)"), + 'vid': _("Configured VLAN ID"), + 'name': _("Configured VLAN name"), + 'status': _("Operational status of this VLAN"), + 'role': _("The primary function of this VLAN"), } widgets = { 'status': StaticSelect(), @@ -800,8 +811,9 @@ class ServiceTemplateForm(NetBoxModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ), - help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") ) + comments = CommentField() fieldsets = ( ('Service Template', ( @@ -811,7 +823,7 @@ class ServiceTemplateForm(NetBoxModelForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'tags') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') widgets = { 'protocol': StaticSelect(), } @@ -831,26 +843,27 @@ class ServiceForm(NetBoxModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ), - help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") ) ipaddresses = DynamicModelMultipleChoiceField( queryset=IPAddress.objects.all(), required=False, - label='IP Addresses', + label=_('IP Addresses'), query_params={ 'device_id': '$device', 'virtual_machine_id': '$virtual_machine', } ) + comments = CommentField() class Meta: model = Service fields = [ - 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', + 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] help_texts = { - 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " - "reachable via all IPs assigned to the device.", + 'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be " + "reachable via all IPs assigned to the device."), } widgets = { 'protocol': StaticSelect(), @@ -907,6 +920,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), @@ -917,7 +931,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', + 'comments', 'tags' ) widgets = { 'type': StaticSelect(), @@ -929,12 +944,12 @@ class L2VPNTerminationForm(NetBoxModelForm): queryset=L2VPN.objects.all(), required=True, query_params={}, - label='L2VPN', + label=_('L2VPN'), fetch_trigger='open' ) device_vlan = DynamicModelChoiceField( queryset=Device.objects.all(), - label="Available on Device", + label=_("Available on Device"), required=False, query_params={} ) @@ -944,7 +959,7 @@ class L2VPNTerminationForm(NetBoxModelForm): query_params={ 'available_on_device': '$device_vlan' }, - label='VLAN' + label=_('VLAN') ) device = DynamicModelChoiceField( queryset=Device.objects.all(), @@ -969,7 +984,7 @@ class L2VPNTerminationForm(NetBoxModelForm): query_params={ 'virtual_machine_id': '$virtual_machine' }, - label='Interface' + label=_('Interface') ) class Meta: diff --git a/netbox/ipam/graphql/gfk_mixins.py b/netbox/ipam/graphql/gfk_mixins.py new file mode 100644 index 000000000..31742c4a4 --- /dev/null +++ b/netbox/ipam/graphql/gfk_mixins.py @@ -0,0 +1,95 @@ +import graphene +from dcim.graphql.types import ( + InterfaceType, + LocationType, + RackType, + RegionType, + SiteGroupType, + SiteType, +) +from dcim.models import Interface, Location, Rack, Region, Site, SiteGroup +from ipam.graphql.types import FHRPGroupType, VLANType +from ipam.models import VLAN, FHRPGroup +from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType +from virtualization.models import Cluster, ClusterGroup, VMInterface + + +class IPAddressAssignmentType(graphene.Union): + class Meta: + types = ( + InterfaceType, + FHRPGroupType, + VMInterfaceType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == Interface: + return InterfaceType + if type(instance) == FHRPGroup: + return FHRPGroupType + if type(instance) == VMInterface: + return VMInterfaceType + + +class L2VPNAssignmentType(graphene.Union): + class Meta: + types = ( + InterfaceType, + VLANType, + VMInterfaceType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == Interface: + return InterfaceType + if type(instance) == VLAN: + return VLANType + if type(instance) == VMInterface: + return VMInterfaceType + + +class FHRPGroupInterfaceType(graphene.Union): + class Meta: + types = ( + InterfaceType, + VMInterfaceType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == Interface: + return InterfaceType + if type(instance) == VMInterface: + return VMInterfaceType + + +class VLANGroupScopeType(graphene.Union): + class Meta: + types = ( + ClusterType, + ClusterGroupType, + LocationType, + RackType, + RegionType, + SiteType, + SiteGroupType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == Cluster: + return ClusterType + if type(instance) == ClusterGroup: + return ClusterGroupType + if type(instance) == Location: + return LocationType + if type(instance) == Rack: + return RackType + if type(instance) == Region: + return RegionType + if type(instance) == Site: + return SiteType + if type(instance) == SiteGroup: + return SiteGroupType diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 5af2ca72a..b8f6221bc 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,5 +1,7 @@ import graphene +from graphene_django import DjangoObjectType +from extras.graphql.mixins import ContactsMixin from ipam import filtersets, models from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -54,18 +56,20 @@ class FHRPGroupType(NetBoxObjectType): class FHRPGroupAssignmentType(BaseObjectType): + interface = graphene.Field('ipam.graphql.gfk_mixins.FHRPGroupInterfaceType') class Meta: model = models.FHRPGroupAssignment - fields = '__all__' + exclude = ('interface_type', 'interface_id') filterset_class = filtersets.FHRPGroupAssignmentFilterSet class IPAddressType(NetBoxObjectType): + assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType') class Meta: model = models.IPAddress - fields = '__all__' + exclude = ('assigned_object_type', 'assigned_object_id') filterset_class = filtersets.IPAddressFilterSet def resolve_role(self, info): @@ -140,10 +144,11 @@ class VLANType(NetBoxObjectType): class VLANGroupType(OrganizationalObjectType): + scope = graphene.Field('ipam.graphql.gfk_mixins.VLANGroupScopeType') class Meta: model = models.VLANGroup - fields = '__all__' + exclude = ('scope_type', 'scope_id') filterset_class = filtersets.VLANGroupFilterSet @@ -155,7 +160,7 @@ class VRFType(NetBoxObjectType): filterset_class = filtersets.VRFFilterSet -class L2VPNType(NetBoxObjectType): +class L2VPNType(ContactsMixin, NetBoxObjectType): class Meta: model = models.L2VPN fields = '__all__' @@ -163,7 +168,9 @@ class L2VPNType(NetBoxObjectType): class L2VPNTerminationType(NetBoxObjectType): + assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType') + class Meta: model = models.L2VPNTermination - fields = '__all__' + exclude = ('assigned_object_type', 'assigned_object_id') filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 545fd46c6..bef36e698 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -29,7 +29,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('date_added', models.DateField(blank=True, null=True)), @@ -44,7 +44,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('address', ipam.fields.IPAddressField()), ('status', models.CharField(default='active', max_length=50)), @@ -64,7 +64,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('status', models.CharField(default='active', max_length=50)), @@ -81,7 +81,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -91,7 +91,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'RIR', 'verbose_name_plural': 'RIRs', - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -99,7 +99,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -107,7 +107,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['weight', 'name'], + 'ordering': ('weight', 'name'), }, ), migrations.CreateModel( @@ -115,7 +115,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=21, unique=True)), ('description', models.CharField(blank=True, max_length=200)), @@ -129,7 +129,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)), @@ -151,7 +151,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -170,7 +170,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), ('name', models.CharField(max_length=64)), @@ -193,7 +193,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0050_iprange.py b/netbox/ipam/migrations/0050_iprange.py index 5b8861f29..374b2547c 100644 --- a/netbox/ipam/migrations/0050_iprange.py +++ b/netbox/ipam/migrations/0050_iprange.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.5 on 2021-07-16 14:15 -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import django.db.models.expressions @@ -22,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('start_address', ipam.fields.IPAddressField()), ('end_address', ipam.fields.IPAddressField()), diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 70219543f..e69e49d48 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('group_id', models.PositiveSmallIntegerField()), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py index 1c7ee8e23..3b074634c 100644 --- a/netbox/ipam/migrations/0053_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.8 on 2021-11-02 16:16 import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('asn', dcim.fields.ASNField(unique=True)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py index 738317907..c8ba6645c 100644 --- a/netbox/ipam/migrations/0055_servicetemplate.py +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('protocol', models.CharField(max_length=50)), ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)), diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index bd4761593..59dbab632 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField()), ('type', models.CharField(max_length=50)), @@ -42,7 +42,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('assigned_object_id', models.PositiveBigIntegerField()), ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), diff --git a/netbox/ipam/migrations/0061_fhrpgroup_name.py b/netbox/ipam/migrations/0061_fhrpgroup_name.py new file mode 100644 index 000000000..7e232c18f --- /dev/null +++ b/netbox/ipam/migrations/0061_fhrpgroup_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.7 on 2022-09-20 23:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0060_alter_l2vpn_slug'), + ] + + operations = [ + migrations.AddField( + model_name='fhrpgroup', + name='name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/ipam/migrations/0062_unique_constraints.py b/netbox/ipam/migrations/0062_unique_constraints.py new file mode 100644 index 000000000..47c1a1214 --- /dev/null +++ b/netbox/ipam/migrations/0062_unique_constraints.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0061_fhrpgroup_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='fhrpgroupassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlan', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='fhrpgroupassignment', + constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id', 'group'), name='ipam_fhrpgroupassignment_unique_interface_group'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'vid'), name='ipam_vlan_unique_group_vid'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='ipam_vlan_unique_group_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'name'), name='ipam_vlangroup_unique_scope_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'slug'), name='ipam_vlangroup_unique_scope_slug'), + ), + ] diff --git a/netbox/ipam/migrations/0063_standardize_description_comments.py b/netbox/ipam/migrations/0063_standardize_description_comments.py new file mode 100644 index 000000000..3a4959d14 --- /dev/null +++ b/netbox/ipam/migrations/0063_standardize_description_comments.py @@ -0,0 +1,73 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0062_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='asn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='fhrpgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='ipaddress', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='iprange', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='l2vpn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='prefix', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='routetarget', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='service', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='servicetemplate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vlan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vrf', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 286251444..759a6e1d3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from netbox.models import ChangeLoggedModel, NetBoxModel +from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,13 +15,17 @@ __all__ = ( ) -class FHRPGroup(NetBoxModel): +class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ group_id = models.PositiveSmallIntegerField( verbose_name='Group ID' ) + name = models.CharField( + max_length=100, + blank=True + ) protocol = models.CharField( max_length=50, choices=FHRPGroupProtocolChoices @@ -37,10 +41,6 @@ class FHRPGroup(NetBoxModel): blank=True, verbose_name='Authentication key' ) - description = models.CharField( - max_length=200, - blank=True - ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', @@ -55,7 +55,11 @@ class FHRPGroup(NetBoxModel): verbose_name = 'FHRP group' def __str__(self): - name = f'{self.get_protocol_display()}: {self.group_id}' + name = '' + if self.name: + name = f'{self.name} ' + + name += f'{self.get_protocol_display()}: {self.group_id}' # Append the first assigned IP addresses (if any) to serve as an additional identifier if self.pk: @@ -94,7 +98,12 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('-priority', 'pk') - unique_together = ('interface_type', 'interface_id', 'group') + constraints = ( + models.UniqueConstraint( + fields=('interface_type', 'interface_id', 'group'), + name='%(app_label)s_%(class)s_unique_interface_group' + ), + ) verbose_name = 'FHRP group assignment' def __str__(self): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 1859ef3e1..e8bf13375 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -6,6 +6,7 @@ from django.db import models from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property +from django.utils.translation import gettext as _ from dcim.fields import ASNField from ipam.choices import * @@ -15,7 +16,7 @@ from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator from netbox.config import get_config -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel __all__ = ( 'Aggregate', @@ -58,37 +59,22 @@ class RIR(OrganizationalModel): A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) is_private = models.BooleanField( default=False, verbose_name='Private', - help_text='IP space managed by this RIR is considered private' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('IP space managed by this RIR is considered private') ) class Meta: - ordering = ['name'] + ordering = ('name',) verbose_name = 'RIR' verbose_name_plural = 'RIRs' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:rir', args=[self.pk]) -class ASN(NetBoxModel): +class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -96,11 +82,7 @@ class ASN(NetBoxModel): asn = ASNField( unique=True, verbose_name='ASN', - help_text='32-bit autonomous system number' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('32-bit autonomous system number') ) rir = models.ForeignKey( to='ipam.RIR', @@ -116,6 +98,10 @@ class ASN(NetBoxModel): null=True ) + prerequisite_models = ( + 'ipam.RIR', + ) + class Meta: ordering = ['asn'] verbose_name = 'ASN' @@ -124,10 +110,6 @@ class ASN(NetBoxModel): def __str__(self): return f'AS{self.asn_with_asdot}' - @classmethod - def get_prerequisite_models(cls): - return [RIR, ] - def get_absolute_url(self): return reverse('ipam:asn', args=[self.pk]) @@ -151,7 +133,7 @@ class ASN(NetBoxModel): return self.asn -class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): +class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -174,14 +156,13 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'rir', 'tenant', 'date_added', 'description', ) + prerequisite_models = ( + 'ipam.RIR', + ) class Meta: ordering = ('prefix', 'pk') # prefix may be non-unique @@ -189,10 +170,6 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): def __str__(self): return str(self.prefix) - @classmethod - def get_prerequisite_models(cls): - return [RIR, ] - def get_absolute_url(self): return reverse('ipam:aggregate', args=[self.pk]) @@ -262,24 +239,12 @@ class Role(OrganizationalModel): A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) weight = models.PositiveSmallIntegerField( default=1000 ) - description = models.CharField( - max_length=200, - blank=True, - ) class Meta: - ordering = ['weight', 'name'] + ordering = ('weight', 'name') def __str__(self): return self.name @@ -288,14 +253,14 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -class Prefix(GetAvailablePrefixesMixin, NetBoxModel): +class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be assigned to a VLAN where appropriate. """ prefix = IPNetworkField( - help_text='IPv4 or IPv6 network with mask' + help_text=_('IPv4 or IPv6 network with mask') ) site = models.ForeignKey( to='dcim.Site', @@ -332,7 +297,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): choices=PrefixStatusChoices, default=PrefixStatusChoices.STATUS_ACTIVE, verbose_name='Status', - help_text='Operational status of this prefix' + help_text=_('Operational status of this prefix') ) role = models.ForeignKey( to='ipam.Role', @@ -340,20 +305,16 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): related_name='prefixes', blank=True, null=True, - help_text='The primary function of this prefix' + help_text=_('The primary function of this prefix') ) is_pool = models.BooleanField( verbose_name='Is a pool', default=False, - help_text='All IP addresses within this prefix are considered usable' + help_text=_('All IP addresses within this prefix are considered usable') ) mark_utilized = models.BooleanField( default=False, - help_text="Treat as 100% utilized" - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_("Treat as 100% utilized") ) # Cached depth & child counts @@ -569,15 +530,15 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): return min(utilization, 100) -class IPRange(NetBoxModel): +class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. """ start_address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) end_address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) size = models.PositiveIntegerField( editable=False @@ -601,7 +562,7 @@ class IPRange(NetBoxModel): max_length=50, choices=IPRangeStatusChoices, default=IPRangeStatusChoices.STATUS_ACTIVE, - help_text='Operational status of this range' + help_text=_('Operational status of this range') ) role = models.ForeignKey( to='ipam.Role', @@ -609,11 +570,7 @@ class IPRange(NetBoxModel): related_name='ip_ranges', blank=True, null=True, - help_text='The primary function of this range' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('The primary function of this range') ) clone_fields = ( @@ -764,7 +721,7 @@ class IPRange(NetBoxModel): return int(float(child_count) / self.size * 100) -class IPAddress(NetBoxModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -776,7 +733,7 @@ class IPAddress(NetBoxModel): which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. """ address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) vrf = models.ForeignKey( to='ipam.VRF', @@ -797,13 +754,13 @@ class IPAddress(NetBoxModel): max_length=50, choices=IPAddressStatusChoices, default=IPAddressStatusChoices.STATUS_ACTIVE, - help_text='The operational status of this IP' + help_text=_('The operational status of this IP') ) role = models.CharField( max_length=50, choices=IPAddressRoleChoices, blank=True, - help_text='The functional role of this IP' + help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( to=ContentType, @@ -828,18 +785,14 @@ class IPAddress(NetBoxModel): blank=True, null=True, verbose_name='NAT (Inside)', - help_text='The IP for which this address is the "outside" IP' + help_text=_('The IP for which this address is the "outside" IP') ) dns_name = models.CharField( max_length=255, blank=True, validators=[DNSValidator], verbose_name='DNS Name', - help_text='Hostname or FQDN (not case-sensitive)' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('Hostname or FQDN (not case-sensitive)') ) objects = IPAddressManager() diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index a457f334b..c858d1a0c 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -8,7 +7,7 @@ from django.utils.functional import cached_property from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS -from netbox.models import NetBoxModel +from netbox.models import NetBoxModel, PrimaryModel __all__ = ( 'L2VPN', @@ -16,7 +15,7 @@ __all__ = ( ) -class L2VPN(NetBoxModel): +class L2VPN(PrimaryModel): name = models.CharField( max_length=100, unique=True @@ -43,10 +42,6 @@ class L2VPN(NetBoxModel): related_name='exporting_l2vpns', blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -99,6 +94,9 @@ class L2VPNTermination(NetBoxModel): ) clone_fields = ('l2vpn',) + prerequisite_models = ( + 'ipam.L2VPN', + ) class Meta: ordering = ('l2vpn',) @@ -115,10 +113,6 @@ class L2VPNTermination(NetBoxModel): return f'{self.assigned_object} <> {self.l2vpn}' return super().__str__() - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('ipam.L2VPN'), ] - def get_absolute_url(self): return reverse('ipam:l2vpntermination', args=[self.pk]) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index b566db375..690abf045 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -6,7 +6,7 @@ from django.urls import reverse from ipam.choices import * from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.utils import array_to_string @@ -30,10 +30,6 @@ class ServiceBase(models.Model): ), verbose_name='Port numbers' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: abstract = True @@ -46,7 +42,7 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -class ServiceTemplate(ServiceBase, NetBoxModel): +class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. """ @@ -62,7 +58,7 @@ class ServiceTemplate(ServiceBase, NetBoxModel): return reverse('ipam:servicetemplate', args=[self.pk]) -class Service(ServiceBase, NetBoxModel): +class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index f0e062721..bf6c6a52e 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -4,16 +4,15 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface - __all__ = ( 'VLAN', 'VLANGroup', @@ -52,7 +51,7 @@ class VLANGroup(OrganizationalModel): MinValueValidator(VLAN_VID_MIN), MaxValueValidator(VLAN_VID_MAX) ), - help_text='Lowest permissible ID of a child VLAN' + help_text=_('Lowest permissible ID of a child VLAN') ) max_vid = models.PositiveSmallIntegerField( verbose_name='Maximum VLAN ID', @@ -61,25 +60,24 @@ class VLANGroup(OrganizationalModel): MinValueValidator(VLAN_VID_MIN), MaxValueValidator(VLAN_VID_MAX) ), - help_text='Highest permissible ID of a child VLAN' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('Highest permissible ID of a child VLAN') ) class Meta: ordering = ('name', 'pk') # Name may be non-unique - unique_together = [ - ['scope_type', 'scope_id', 'name'], - ['scope_type', 'scope_id', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'name'), + name='%(app_label)s_%(class)s_unique_scope_name' + ), + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'slug'), + name='%(app_label)s_%(class)s_unique_scope_slug' + ), + ) verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:vlangroup', args=[self.pk]) @@ -117,7 +115,7 @@ class VLANGroup(OrganizationalModel): return None -class VLAN(NetBoxModel): +class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, @@ -169,10 +167,6 @@ class VLAN(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', @@ -189,10 +183,16 @@ class VLAN(NetBoxModel): class Meta: ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] + constraints = ( + models.UniqueConstraint( + fields=('group', 'vid'), + name='%(app_label)s_%(class)s_unique_group_vid' + ), + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + ) verbose_name = 'VLAN' verbose_name_plural = 'VLANs' diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index a926bec3e..a1a53b3a7 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,8 +1,9 @@ from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( @@ -11,7 +12,7 @@ __all__ = ( ) -class VRF(NetBoxModel): +class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -26,7 +27,7 @@ class VRF(NetBoxModel): blank=True, null=True, verbose_name='Route distinguisher', - help_text='Unique route distinguisher (as defined in RFC 4364)' + help_text=_('Unique route distinguisher (as defined in RFC 4364)') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -38,11 +39,7 @@ class VRF(NetBoxModel): enforce_unique = models.BooleanField( default=True, verbose_name='Enforce unique space', - help_text='Prevent duplicate prefixes/IP addresses within this VRF' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('Prevent duplicate prefixes/IP addresses within this VRF') ) import_targets = models.ManyToManyField( to='ipam.RouteTarget', @@ -73,18 +70,14 @@ class VRF(NetBoxModel): return reverse('ipam:vrf', args=[self.pk]) -class RouteTarget(NetBoxModel): +class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ name = models.CharField( max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) unique=True, - help_text='Route target value (formatted in accordance with RFC 4360)' - ) - description = models.CharField( - max_length=200, - blank=True + help_text=_('Route target value (formatted in accordance with RFC 4360)') ) tenant = models.ForeignKey( to='tenancy.Tenant', diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py new file mode 100644 index 000000000..fd6db6a63 --- /dev/null +++ b/netbox/ipam/search.py @@ -0,0 +1,159 @@ +from . import models +from netbox.search import SearchIndex, register_search + + +@register_search +class AggregateIndex(SearchIndex): + model = models.Aggregate + fields = ( + ('prefix', 100), + ('description', 500), + ('date_added', 2000), + ('comments', 5000), + ) + + +@register_search +class ASNIndex(SearchIndex): + model = models.ASN + fields = ( + ('asn', 100), + ('description', 500), + ) + + +@register_search +class FHRPGroupIndex(SearchIndex): + model = models.FHRPGroup + fields = ( + ('name', 100), + ('group_id', 2000), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class IPAddressIndex(SearchIndex): + model = models.IPAddress + fields = ( + ('address', 100), + ('dns_name', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class IPRangeIndex(SearchIndex): + model = models.IPRange + fields = ( + ('start_address', 100), + ('end_address', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class L2VPNIndex(SearchIndex): + model = models.L2VPN + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class PrefixIndex(SearchIndex): + model = models.Prefix + fields = ( + ('prefix', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class RIRIndex(SearchIndex): + model = models.RIR + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class RoleIndex(SearchIndex): + model = models.Role + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class RouteTargetIndex(SearchIndex): + model = models.RouteTarget + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ServiceIndex(SearchIndex): + model = models.Service + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ServiceTemplateIndex(SearchIndex): + model = models.ServiceTemplate + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class VLANIndex(SearchIndex): + model = models.VLAN + fields = ( + ('name', 100), + ('vid', 100), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class VLANGroupIndex(SearchIndex): + model = models.VLANGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('max_vid', 2000), + ) + + +@register_search +class VRFIndex(SearchIndex): + model = models.VRF + fields = ( + ('name', 100), + ('rd', 200), + ('description', 500), + ('comments', 5000), + ) diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index f709bfeb2..89aa16e65 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -20,7 +20,6 @@ class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) - comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -29,6 +28,7 @@ class FHRPGroupTable(NetBoxTable): member_count = tables.Column( verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) @@ -36,10 +36,12 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', - 'tags', 'created', 'last_updated', + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'comments', 'ip_addresses', + 'member_count', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'description', 'ip_addresses', 'member_count', ) - default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') class FHRPGroupAssignmentTable(NetBoxTable): diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 44f40b8a1..f83831d2d 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -120,6 +120,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): linkify_item=True, verbose_name='Sites' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:asn_list' ) @@ -127,8 +128,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags', - 'created', 'last_updated', 'actions', + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', + 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') @@ -153,6 +154,7 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:aggregate_list' ) @@ -160,8 +162,8 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Aggregate fields = ( - 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -278,6 +280,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:prefix_list' ) @@ -285,8 +288,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site', - 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', + 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -317,6 +321,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): accessor='utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:iprange_list' ) @@ -324,8 +329,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPRange fields = ( - 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description', - 'utilization', 'tags', 'created', 'last_updated', + 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', + 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -378,6 +383,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): linkify=lambda record: record.assigned_object.get_absolute_url(), verbose_name='Assigned' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) @@ -385,8 +391,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPAddress fields = ( - 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', + 'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 628f9b8f0..4ccd83e47 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -29,6 +29,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): template_code=L2VPN_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:l2vpn_list' ) @@ -36,8 +37,8 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', - 'tenant_group', 'tags', 'created', 'last_updated', + 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'identifier', 'type', 'description') diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 58d0a9aff..826ac98d5 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -17,13 +17,16 @@ class ServiceTemplateTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:servicetemplate_list' ) class Meta(NetBoxTable.Meta): model = ServiceTemplate - fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'protocol', 'ports', 'description') @@ -39,6 +42,7 @@ class ServiceTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:service_list' ) @@ -46,7 +50,7 @@ class ServiceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Service fields = ( - 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index f183f8a7b..6fa2cd2da 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -121,6 +121,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Prefixes' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vlan_list' ) @@ -129,7 +130,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', - 'description', 'tags', 'l2vpn', 'created', 'last_updated', + 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 69807410b..635af48d0 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -38,6 +38,7 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): template_code=VRF_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) @@ -45,8 +46,8 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'import_targets', 'export_targets', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -59,11 +60,14 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) class Meta(NetBoxTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',) + fields = ( + 'pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0fefb0162..ea6441650 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -552,6 +552,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase): 'group_id': 200, 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'foobarbaz999', + 'name': 'foobar-999', 'description': 'New description', } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index d67994fc1..a2b06080a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -932,7 +932,7 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): fhrp_groups = ( FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foo123'), - FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456', name='bar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), ) FHRPGroup.objects.bulk_create(fhrp_groups) @@ -956,6 +956,10 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'auth_key': ['foo123', 'bar456']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): + params = {'name': ['bar123', ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_related_ip(self): # Create some regular IPs to query for related IPs ipaddresses = ( diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 690501e53..8d69af847 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -9,12 +9,17 @@ import netaddr class OrderingTestBase(TestCase): vrfs = None - def setUp(self): + @classmethod + def setUpTestData(cls): """ Setup the VRFs for the class as a whole """ - self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) - VRF.objects.bulk_create(self.vrfs) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) def _compare(self, queryset, objectset): """ @@ -37,10 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase): """ This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Prefixes + vrf1, vrf2, vrf3 = list(VRF.objects.all()) prefixes = ( Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')), @@ -50,37 +52,37 @@ class PrefixOrderingTestCase(OrderingTestBase): Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.4.0/24')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.5.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/8')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/12')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/12')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.4.0/24')), ) Prefix.objects.bulk_create(prefixes) @@ -104,20 +106,17 @@ class PrefixOrderingTestCase(OrderingTestBase): VRF A:10.1.1.0/24 None: 192.168.0.0/16 """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Prefixes + vrf1, vrf2, vrf3 = list(VRF.objects.all()) prefixes = [ Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('10.1.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/25')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/25')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')), ] Prefix.objects.bulk_create(prefixes) @@ -131,37 +130,34 @@ class IPAddressOrderingTestCase(OrderingTestBase): """ This function tests ordering with the inclusion of vrfs """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Addresses + vrf1, vrf2, vrf3 = list(VRF.objects.all()) addresses = ( - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.4.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.0.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.1.1/24')), diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 27520229a..8bf19ebfa 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -60,6 +60,13 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): "4200000002,RFC 6996", ) + cls.csv_update_data = ( + "id,description", + f"{asns[0].pk},New description1", + f"{asns[1].pk},New description2", + f"{asns[2].pk},New description3", + ) + cls.bulk_edit_data = { 'rir': rirs[1].pk, 'description': 'Next description', @@ -78,11 +85,12 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Tenant.objects.bulk_create(tenants) - VRF.objects.bulk_create([ + vrfs = ( VRF(name='VRF 1', rd='65000:1'), VRF(name='VRF 2', rd='65000:2'), VRF(name='VRF 3', rd='65000:3'), - ]) + ) + VRF.objects.bulk_create(vrfs) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -102,6 +110,13 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 6", ) + cls.csv_update_data = ( + "id,name", + f"{vrfs[0].pk},VRF 7", + f"{vrfs[1].pk},VRF 8", + f"{vrfs[2].pk},VRF 9", + ) + cls.bulk_edit_data = { 'tenant': tenants[1].pk, 'enforce_unique': False, @@ -143,6 +158,13 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase): "65000:1006,,No tenant", ) + cls.csv_update_data = ( + "id,name,description", + f"{route_targets[0].pk},65000:1007,New description1", + f"{route_targets[1].pk},65000:1008,New description2", + f"{route_targets[2].pk},65000:1009,New description3", + ) + cls.bulk_edit_data = { 'tenant': tenants[1].pk, 'description': 'New description', @@ -155,11 +177,12 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - RIR.objects.bulk_create([ + rirs = ( RIR(name='RIR 1', slug='rir-1'), RIR(name='RIR 2', slug='rir-2'), RIR(name='RIR 3', slug='rir-3'), - ]) + ) + RIR.objects.bulk_create(rirs) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -178,6 +201,13 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "RIR 6,rir-6,Sixth RIR", ) + cls.csv_update_data = ( + "id,name,description", + f"{rirs[0].pk},RIR 7,Fourth RIR7", + f"{rirs[1].pk},RIR 8,Fifth RIR8", + f"{rirs[2].pk},RIR 9,Sixth RIR9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -195,11 +225,12 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RIR.objects.bulk_create(rirs) - Aggregate.objects.bulk_create([ + aggregates = ( Aggregate(prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), - ]) + ) + Aggregate.objects.bulk_create(aggregates) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -218,6 +249,13 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): "10.6.0.0/16,RIR 1", ) + cls.csv_update_data = ( + "id,description", + f"{aggregates[0].pk},New description1", + f"{aggregates[1].pk},New description2", + f"{aggregates[2].pk},New description3", + ) + cls.bulk_edit_data = { 'rir': rirs[1].pk, 'date_added': datetime.date(2020, 1, 1), @@ -246,11 +284,12 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Role.objects.bulk_create([ + roles = ( Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), Role(name='Role 3', slug='role-3'), - ]) + ) + Role.objects.bulk_create(roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -269,6 +308,13 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Role 6,role-6,1000", ) + cls.csv_update_data = ( + "id,name,description", + f"{roles[0].pk},Role 7,New description7", + f"{roles[1].pk},Role 8,New description8", + f"{roles[2].pk},Role 9,New description9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -298,11 +344,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Role.objects.bulk_create(roles) - Prefix.objects.bulk_create([ + prefixes = ( Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), - ]) + ) + Prefix.objects.bulk_create(prefixes) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -326,6 +373,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,10.6.0.0/16,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}", + f"{prefixes[1].pk},New description 8,{PrefixStatusChoices.STATUS_RESERVED}", + f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'vrf': vrfs[1].pk, @@ -428,6 +482,13 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,10.3.0.1/16,10.3.9.254/16,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}", + f"{ip_ranges[1].pk},New description 8,{IPRangeStatusChoices.STATUS_RESERVED}", + f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -467,11 +528,12 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) VRF.objects.bulk_create(vrfs) - IPAddress.objects.bulk_create([ + ipaddresses = ( IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), - ]) + ) + IPAddress.objects.bulk_create(ipaddresses) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -494,6 +556,13 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,192.0.2.6/24,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}", + f"{ipaddresses[1].pk},New description 8,{IPAddressStatusChoices.STATUS_RESERVED}", + f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -510,11 +579,12 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - FHRPGroup.objects.bulk_create(( + fhrp_groups = ( FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), - )) + ) + FHRPGroup.objects.bulk_create(fhrp_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -524,6 +594,7 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'abc123def456', 'description': 'Blah blah blah', + 'name': 'test123 name', 'tags': [t.pk for t in tags], } @@ -534,6 +605,13 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): "hsrp,60,,,", ) + cls.csv_update_data = ( + "id,name,description", + f"{fhrp_groups[0].pk},FHRP Group 1,New description 1", + f"{fhrp_groups[1].pk},FHRP Group 2,New description 2", + f"{fhrp_groups[2].pk},FHRP Group 3,New description 3", + ) + cls.bulk_edit_data = { 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, } @@ -551,11 +629,12 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) Site.objects.bulk_create(sites) - VLANGroup.objects.bulk_create([ + vlan_groups = ( VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), - ]) + ) + VLANGroup.objects.bulk_create(vlan_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -575,6 +654,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", ) + cls.csv_update_data = ( + f"id,name,description", + f"{vlan_groups[0].pk},VLAN Group 7,Fourth VLAN group7", + f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8", + f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -604,11 +690,12 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Role.objects.bulk_create(roles) - VLAN.objects.bulk_create([ + vlans = ( VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), - ]) + ) + VLAN.objects.bulk_create(vlans) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -631,6 +718,13 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): "106,VLAN106,active", ) + cls.csv_update_data = ( + "id,name,description", + f"{vlans[0].pk},VLAN107,New description 7", + f"{vlans[1].pk},VLAN108,New description 8", + f"{vlans[2].pk},VLAN109,New description 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'group': vlangroups[1].pk, @@ -646,11 +740,12 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - ServiceTemplate.objects.bulk_create([ + service_templates = ( ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), - ]) + ) + ServiceTemplate.objects.bulk_create(service_templates) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -669,6 +764,13 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Service Template 6,tcp,3,Third service template", ) + cls.csv_update_data = ( + "id,name,description", + f"{service_templates[0].pk},Service Template 7,First service template7", + f"{service_templates[1].pk},Service Template 8,Second service template8", + f"{service_templates[2].pk},Service Template 9,Third service template9", + ) + cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'ports': '106,107', @@ -688,11 +790,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - Service.objects.bulk_create([ + services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), - ]) + ) + Service.objects.bulk_create(services) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -714,6 +817,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Device 1,Service 3,udp,3,Third service", ) + cls.csv_update_data = ( + "id,name,description", + f"{services[0].pk},Service 7,First service7", + f"{services[1].pk},Service 8,Second service8", + f"{services[2].pk},Service 9,Third service9", + ) + cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'ports': '106,107', @@ -750,14 +860,6 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = L2VPN - csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', - ) - bulk_edit_data = { - 'description': 'New Description', - } @classmethod def setUpTestData(cls): @@ -772,9 +874,24 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') ) - L2VPN.objects.bulk_create(l2vpns) + cls.csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + + cls.csv_update_data = ( + 'id,name,description', + f'{l2vpns[0].pk},L2VPN 7,New description 7', + f'{l2vpns[1].pk},L2VPN 8,New description 8', + ) + + cls.bulk_edit_data = { + 'description': 'New Description', + } + cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', @@ -803,7 +920,11 @@ class L2VPNTerminationTestCase( def setUpTestData(cls): device = create_test_device('Device 1') interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') - l2vpn = L2VPN.objects.create(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002), + ) + L2VPN.objects.bulk_create(l2vpns) vlans = ( VLAN(name='Vlan 1', vid=1001), @@ -816,14 +937,14 @@ class L2VPNTerminationTestCase( VLAN.objects.bulk_create(vlans) terminations = ( - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) L2VPNTermination.objects.bulk_create(terminations) cls.form_data = { - 'l2vpn': l2vpn.pk, + 'l2vpn': l2vpns[0].pk, 'device': device.pk, 'interface': interface.pk, } @@ -835,6 +956,13 @@ class L2VPNTerminationTestCase( "L2VPN 1,Vlan 6", ) + cls.csv_update_data = ( + f"id,l2vpn", + f"{terminations[0].pk},{l2vpns[0].name}", + f"{terminations[1].pk},{l2vpns[0].name}", + f"{terminations[2].pk},{l2vpns[0].name}", + ) + cls.bulk_edit_data = {} # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index d27209fd2..032ddf498 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'ipam' urlpatterns = [ @@ -13,11 +12,7 @@ urlpatterns = [ path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'), path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'), path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'), - path('asns//', views.ASNView.as_view(), name='asn'), - path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), - path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), - path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), - path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + path('asns//', include(get_model_urls('ipam', 'asn'))), # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), @@ -25,11 +20,7 @@ urlpatterns = [ path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - path('vrfs//', views.VRFView.as_view(), name='vrf'), - path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), - path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), - path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), - path('vrfs//journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}), + path('vrfs//', include(get_model_urls('ipam', 'vrf'))), # Route targets path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), @@ -37,11 +28,7 @@ urlpatterns = [ path('route-targets/import/', views.RouteTargetBulkImportView.as_view(), name='routetarget_import'), path('route-targets/edit/', views.RouteTargetBulkEditView.as_view(), name='routetarget_bulk_edit'), path('route-targets/delete/', views.RouteTargetBulkDeleteView.as_view(), name='routetarget_bulk_delete'), - path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), - path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), - path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), - path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), - path('route-targets//journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}), + path('route-targets//', include(get_model_urls('ipam', 'routetarget'))), # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), @@ -49,10 +36,7 @@ urlpatterns = [ path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - path('rirs//', views.RIRView.as_view(), name='rir'), - path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), - path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), - path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs//', include(get_model_urls('ipam', 'rir'))), # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), @@ -60,12 +44,7 @@ urlpatterns = [ path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - path('aggregates//', views.AggregateView.as_view(), name='aggregate'), - path('aggregates//prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'), - path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), - path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), - path('aggregates//journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}), + path('aggregates//', include(get_model_urls('ipam', 'aggregate'))), # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), @@ -73,10 +52,7 @@ urlpatterns = [ path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - path('roles//', views.RoleView.as_view(), name='role'), - path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), - path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), - path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles//', include(get_model_urls('ipam', 'role'))), # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), @@ -84,14 +60,7 @@ urlpatterns = [ path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - path('prefixes//', views.PrefixView.as_view(), name='prefix'), - path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), - path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - path('prefixes//journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}), - path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - path('prefixes//ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'), - path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path('prefixes//', include(get_model_urls('ipam', 'prefix'))), # IP ranges path('ip-ranges/', views.IPRangeListView.as_view(), name='iprange_list'), @@ -99,12 +68,7 @@ urlpatterns = [ path('ip-ranges/import/', views.IPRangeBulkImportView.as_view(), name='iprange_import'), path('ip-ranges/edit/', views.IPRangeBulkEditView.as_view(), name='iprange_bulk_edit'), path('ip-ranges/delete/', views.IPRangeBulkDeleteView.as_view(), name='iprange_bulk_delete'), - path('ip-ranges//', views.IPRangeView.as_view(), name='iprange'), - path('ip-ranges//edit/', views.IPRangeEditView.as_view(), name='iprange_edit'), - path('ip-ranges//delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'), - path('ip-ranges//changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}), - path('ip-ranges//journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}), - path('ip-ranges//ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'), + path('ip-ranges//', include(get_model_urls('ipam', 'iprange'))), # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), @@ -113,12 +77,8 @@ urlpatterns = [ path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - path('ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - path('ip-addresses//journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}), path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), - path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), - path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path('ip-addresses//', include(get_model_urls('ipam', 'ipaddress'))), # FHRP groups path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), @@ -126,16 +86,11 @@ urlpatterns = [ path('fhrp-groups/import/', views.FHRPGroupBulkImportView.as_view(), name='fhrpgroup_import'), path('fhrp-groups/edit/', views.FHRPGroupBulkEditView.as_view(), name='fhrpgroup_bulk_edit'), path('fhrp-groups/delete/', views.FHRPGroupBulkDeleteView.as_view(), name='fhrpgroup_bulk_delete'), - path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), - path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), - path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), - path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), - path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + path('fhrp-groups//', include(get_model_urls('ipam', 'fhrpgroup'))), # FHRP group assignments path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'), - path('fhrp-group-assignments//edit/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_edit'), - path('fhrp-group-assignments//delete/', views.FHRPGroupAssignmentDeleteView.as_view(), name='fhrpgroupassignment_delete'), + path('fhrp-group-assignments//', include(get_model_urls('ipam', 'fhrpgroupassignment'))), # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), @@ -143,10 +98,7 @@ urlpatterns = [ path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), - path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), - path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), - path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path('vlan-groups//', include(get_model_urls('ipam', 'vlangroup'))), # VLANs path('vlans/', views.VLANListView.as_view(), name='vlan_list'), @@ -154,13 +106,7 @@ urlpatterns = [ path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - path('vlans//', views.VLANView.as_view(), name='vlan'), - path('vlans//interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'), - path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), - path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), - path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), - path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), - path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), + path('vlans//', include(get_model_urls('ipam', 'vlan'))), # Service templates path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), @@ -168,11 +114,7 @@ urlpatterns = [ path('service-templates/import/', views.ServiceTemplateBulkImportView.as_view(), name='servicetemplate_import'), path('service-templates/edit/', views.ServiceTemplateBulkEditView.as_view(), name='servicetemplate_bulk_edit'), path('service-templates/delete/', views.ServiceTemplateBulkDeleteView.as_view(), name='servicetemplate_bulk_delete'), - path('service-templates//', views.ServiceTemplateView.as_view(), name='servicetemplate'), - path('service-templates//edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'), - path('service-templates//delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'), - path('service-templates//changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}), - path('service-templates//journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}), + path('service-templates//', include(get_model_urls('ipam', 'servicetemplate'))), # Services path('services/', views.ServiceListView.as_view(), name='service_list'), @@ -180,11 +122,7 @@ urlpatterns = [ path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), - path('services//', views.ServiceView.as_view(), name='service'), - path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), - path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), - path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), - path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + path('services//', include(get_model_urls('ipam', 'service'))), # L2VPN path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), @@ -192,20 +130,13 @@ urlpatterns = [ path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), - path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), - path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), - path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns//', include(get_model_urls('ipam', 'l2vpn'))), + # L2VPN terminations path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), - path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), - path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), - path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//', include(get_model_urls('ipam', 'l2vpntermination'))), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 72483d40f..130014f3f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,6 +3,7 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ from circuits.models import Provider, Circuit from circuits.tables import ProviderTable @@ -11,6 +12,7 @@ from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related +from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables @@ -32,6 +34,7 @@ class VRFListView(generic.ObjectListView): table = tables.VRFTable +@register_model_view(VRF) class VRFView(generic.ObjectView): queryset = VRF.objects.all() @@ -56,18 +59,20 @@ class VRFView(generic.ObjectView): } +@register_model_view(VRF, 'edit') class VRFEditView(generic.ObjectEditView): queryset = VRF.objects.all() form = forms.VRFForm +@register_model_view(VRF, 'delete') class VRFDeleteView(generic.ObjectDeleteView): queryset = VRF.objects.all() class VRFBulkImportView(generic.BulkImportView): queryset = VRF.objects.all() - model_form = forms.VRFCSVForm + model_form = forms.VRFImportForm table = tables.VRFTable @@ -95,6 +100,7 @@ class RouteTargetListView(generic.ObjectListView): table = tables.RouteTargetTable +@register_model_view(RouteTarget) class RouteTargetView(generic.ObjectView): queryset = RouteTarget.objects.all() @@ -114,18 +120,20 @@ class RouteTargetView(generic.ObjectView): } +@register_model_view(RouteTarget, 'edit') class RouteTargetEditView(generic.ObjectEditView): queryset = RouteTarget.objects.all() form = forms.RouteTargetForm +@register_model_view(RouteTarget, 'delete') class RouteTargetDeleteView(generic.ObjectDeleteView): queryset = RouteTarget.objects.all() class RouteTargetBulkImportView(generic.BulkImportView): queryset = RouteTarget.objects.all() - model_form = forms.RouteTargetCSVForm + model_form = forms.RouteTargetImportForm table = tables.RouteTargetTable @@ -155,6 +163,7 @@ class RIRListView(generic.ObjectListView): table = tables.RIRTable +@register_model_view(RIR) class RIRView(generic.ObjectView): queryset = RIR.objects.all() @@ -170,18 +179,20 @@ class RIRView(generic.ObjectView): } +@register_model_view(RIR, 'edit') class RIREditView(generic.ObjectEditView): queryset = RIR.objects.all() form = forms.RIRForm +@register_model_view(RIR, 'delete') class RIRDeleteView(generic.ObjectDeleteView): queryset = RIR.objects.all() class RIRBulkImportView(generic.BulkImportView): queryset = RIR.objects.all() - model_form = forms.RIRCSVForm + model_form = forms.RIRImportForm table = tables.RIRTable @@ -216,6 +227,7 @@ class ASNListView(generic.ObjectListView): table = tables.ASNTable +@register_model_view(ASN) class ASNView(generic.ObjectView): queryset = ASN.objects.all() @@ -240,18 +252,20 @@ class ASNView(generic.ObjectView): } +@register_model_view(ASN, 'edit') class ASNEditView(generic.ObjectEditView): queryset = ASN.objects.all() form = forms.ASNForm +@register_model_view(ASN, 'delete') class ASNDeleteView(generic.ObjectDeleteView): queryset = ASN.objects.all() class ASNBulkImportView(generic.BulkImportView): queryset = ASN.objects.all() - model_form = forms.ASNCSVForm + model_form = forms.ASNImportForm table = tables.ASNTable @@ -285,16 +299,24 @@ class AggregateListView(generic.ObjectListView): table = tables.AggregateTable +@register_model_view(Aggregate) class AggregateView(generic.ObjectView): queryset = Aggregate.objects.all() +@register_model_view(Aggregate, 'prefixes') class AggregatePrefixesView(generic.ObjectChildrenView): queryset = Aggregate.objects.all() child_model = Prefix table = tables.PrefixTable filterset = filtersets.PrefixFilterSet template_name = 'ipam/aggregate/prefixes.html' + tab = ViewTab( + label=_('Prefixes'), + badge=lambda x: x.get_child_prefixes().count(), + permission='ipam.view_prefix', + weight=500 + ) def get_children(self, request, parent): return Prefix.objects.restrict(request.user, 'view').filter( @@ -311,25 +333,26 @@ class AggregatePrefixesView(generic.ObjectChildrenView): def get_extra_context(self, request, instance): return { 'bulk_querystring': f'within={instance.prefix}', - 'active_tab': 'prefixes', 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': bool(request.GET.get('show_available', 'true') == 'true'), 'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'), } +@register_model_view(Aggregate, 'edit') class AggregateEditView(generic.ObjectEditView): queryset = Aggregate.objects.all() form = forms.AggregateForm +@register_model_view(Aggregate, 'delete') class AggregateDeleteView(generic.ObjectDeleteView): queryset = Aggregate.objects.all() class AggregateBulkImportView(generic.BulkImportView): queryset = Aggregate.objects.all() - model_form = forms.AggregateCSVForm + model_form = forms.AggregateImportForm table = tables.AggregateTable @@ -365,6 +388,7 @@ class RoleListView(generic.ObjectListView): table = tables.RoleTable +@register_model_view(Role) class RoleView(generic.ObjectView): queryset = Role.objects.all() @@ -381,18 +405,20 @@ class RoleView(generic.ObjectView): } +@register_model_view(Role, 'edit') class RoleEditView(generic.ObjectEditView): queryset = Role.objects.all() form = forms.RoleForm +@register_model_view(Role, 'delete') class RoleDeleteView(generic.ObjectDeleteView): queryset = Role.objects.all() class RoleBulkImportView(generic.BulkImportView): queryset = Role.objects.all() - model_form = forms.RoleCSVForm + model_form = forms.RoleImportForm table = tables.RoleTable @@ -420,6 +446,7 @@ class PrefixListView(generic.ObjectListView): template_name = 'ipam/prefix_list.html' +@register_model_view(Prefix) class PrefixView(generic.ObjectView): queryset = Prefix.objects.all() @@ -466,12 +493,19 @@ class PrefixView(generic.ObjectView): } +@register_model_view(Prefix, 'prefixes') class PrefixPrefixesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = Prefix table = tables.PrefixTable filterset = filtersets.PrefixFilterSet template_name = 'ipam/prefix/prefixes.html' + tab = ViewTab( + label=_('Child Prefixes'), + badge=lambda x: x.get_child_prefixes().count(), + permission='ipam.view_prefix', + weight=500 + ) def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( @@ -488,19 +522,25 @@ class PrefixPrefixesView(generic.ObjectChildrenView): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}", - 'active_tab': 'prefixes', 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': bool(request.GET.get('show_available', 'true') == 'true'), 'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'), } +@register_model_view(Prefix, 'ipranges', path='ip-ranges') class PrefixIPRangesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = IPRange table = tables.IPRangeTable filterset = filtersets.IPRangeFilterSet template_name = 'ipam/prefix/ip_ranges.html' + tab = ViewTab( + label=_('Child Ranges'), + badge=lambda x: x.get_child_ranges().count(), + permission='ipam.view_iprange', + weight=600 + ) def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( @@ -510,17 +550,23 @@ class PrefixIPRangesView(generic.ObjectChildrenView): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ip-ranges', 'first_available_ip': instance.get_first_available_ip(), } +@register_model_view(Prefix, 'ipaddresses', path='ip-addresses') class PrefixIPAddressesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet template_name = 'ipam/prefix/ip_addresses.html' + tab = ViewTab( + label=_('IP Addresses'), + badge=lambda x: x.get_child_ips().count(), + permission='ipam.view_ipaddress', + weight=700 + ) def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') @@ -533,23 +579,24 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ip-addresses', 'first_available_ip': instance.get_first_available_ip(), } +@register_model_view(Prefix, 'edit') class PrefixEditView(generic.ObjectEditView): queryset = Prefix.objects.all() form = forms.PrefixForm +@register_model_view(Prefix, 'delete') class PrefixDeleteView(generic.ObjectDeleteView): queryset = Prefix.objects.all() class PrefixBulkImportView(generic.BulkImportView): queryset = Prefix.objects.all() - model_form = forms.PrefixCSVForm + model_form = forms.PrefixImportForm table = tables.PrefixTable @@ -577,38 +624,43 @@ class IPRangeListView(generic.ObjectListView): table = tables.IPRangeTable +@register_model_view(IPRange) class IPRangeView(generic.ObjectView): queryset = IPRange.objects.all() +@register_model_view(IPRange, 'ipaddresses', path='ip-addresses') class IPRangeIPAddressesView(generic.ObjectChildrenView): queryset = IPRange.objects.all() child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet template_name = 'ipam/iprange/ip_addresses.html' + tab = ViewTab( + label=_('IP Addresses'), + badge=lambda x: x.get_child_ips().count(), + permission='ipam.view_ipaddress', + weight=500 + ) def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'ip-addresses', - } - +@register_model_view(IPRange, 'edit') class IPRangeEditView(generic.ObjectEditView): queryset = IPRange.objects.all() form = forms.IPRangeForm +@register_model_view(IPRange, 'delete') class IPRangeDeleteView(generic.ObjectDeleteView): queryset = IPRange.objects.all() class IPRangeBulkImportView(generic.BulkImportView): queryset = IPRange.objects.all() - model_form = forms.IPRangeCSVForm + model_form = forms.IPRangeImportForm table = tables.IPRangeTable @@ -636,6 +688,7 @@ class IPAddressListView(generic.ObjectListView): table = tables.IPAddressTable +@register_model_view(IPAddress) class IPAddressView(generic.ObjectView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') @@ -703,6 +756,7 @@ class IPAddressView(generic.ObjectView): } +@register_model_view(IPAddress, 'edit') class IPAddressEditView(generic.ObjectEditView): queryset = IPAddress.objects.all() form = forms.IPAddressForm @@ -772,6 +826,7 @@ class IPAddressAssignView(generic.ObjectView): }) +@register_model_view(IPAddress, 'delete') class IPAddressDeleteView(generic.ObjectDeleteView): queryset = IPAddress.objects.all() @@ -786,7 +841,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView): class IPAddressBulkImportView(generic.BulkImportView): queryset = IPAddress.objects.all() - model_form = forms.IPAddressCSVForm + model_form = forms.IPAddressImportForm table = tables.IPAddressTable @@ -816,6 +871,7 @@ class VLANGroupListView(generic.ObjectListView): table = tables.VLANGroupTable +@register_model_view(VLANGroup) class VLANGroupView(generic.ObjectView): queryset = VLANGroup.objects.all() @@ -846,18 +902,20 @@ class VLANGroupView(generic.ObjectView): } +@register_model_view(VLANGroup, 'edit') class VLANGroupEditView(generic.ObjectEditView): queryset = VLANGroup.objects.all() form = forms.VLANGroupForm +@register_model_view(VLANGroup, 'delete') class VLANGroupDeleteView(generic.ObjectDeleteView): queryset = VLANGroup.objects.all() class VLANGroupBulkImportView(generic.BulkImportView): queryset = VLANGroup.objects.all() - model_form = forms.VLANGroupCSVForm + model_form = forms.VLANGroupImportForm table = tables.VLANGroupTable @@ -891,6 +949,7 @@ class FHRPGroupListView(generic.ObjectListView): table = tables.FHRPGroupTable +@register_model_view(FHRPGroup) class FHRPGroupView(generic.ObjectView): queryset = FHRPGroup.objects.all() @@ -915,6 +974,7 @@ class FHRPGroupView(generic.ObjectView): } +@register_model_view(FHRPGroup, 'edit') class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() form = forms.FHRPGroupForm @@ -937,13 +997,14 @@ class FHRPGroupEditView(generic.ObjectEditView): return obj +@register_model_view(FHRPGroup, 'delete') class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() class FHRPGroupBulkImportView(generic.BulkImportView): queryset = FHRPGroup.objects.all() - model_form = forms.FHRPGroupCSVForm + model_form = forms.FHRPGroupImportForm table = tables.FHRPGroupTable @@ -964,6 +1025,7 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView): # FHRP group assignments # +@register_model_view(FHRPGroupAssignment, 'edit') class FHRPGroupAssignmentEditView(generic.ObjectEditView): queryset = FHRPGroupAssignment.objects.all() form = forms.FHRPGroupAssignmentForm @@ -977,6 +1039,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): return instance +@register_model_view(FHRPGroupAssignment, 'delete') class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = FHRPGroupAssignment.objects.all() @@ -992,6 +1055,7 @@ class VLANListView(generic.ObjectListView): table = tables.VLANTable +@register_model_view(VLAN) class VLANView(generic.ObjectView): queryset = VLAN.objects.all() @@ -1006,51 +1070,57 @@ class VLANView(generic.ObjectView): } +@register_model_view(VLAN, 'interfaces') class VLANInterfacesView(generic.ObjectChildrenView): queryset = VLAN.objects.all() child_model = Interface table = tables.VLANDevicesTable filterset = InterfaceFilterSet template_name = 'ipam/vlan/interfaces.html' + tab = ViewTab( + label=_('Device Interfaces'), + badge=lambda x: x.get_interfaces().count(), + permission='dcim.view_interface', + weight=500 + ) def get_children(self, request, parent): return parent.get_interfaces().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'interfaces', - } - +@register_model_view(VLAN, 'vminterfaces', path='vm-interfaces') class VLANVMInterfacesView(generic.ObjectChildrenView): queryset = VLAN.objects.all() child_model = VMInterface table = tables.VLANVirtualMachinesTable filterset = VMInterfaceFilterSet template_name = 'ipam/vlan/vminterfaces.html' + tab = ViewTab( + label=_('VM Interfaces'), + badge=lambda x: x.get_vminterfaces().count(), + permission='virtualization.view_vminterface', + weight=510 + ) def get_children(self, request, parent): return parent.get_vminterfaces().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'vminterfaces', - } - +@register_model_view(VLAN, 'edit') class VLANEditView(generic.ObjectEditView): queryset = VLAN.objects.all() form = forms.VLANForm template_name = 'ipam/vlan_edit.html' +@register_model_view(VLAN, 'delete') class VLANDeleteView(generic.ObjectDeleteView): queryset = VLAN.objects.all() class VLANBulkImportView(generic.BulkImportView): queryset = VLAN.objects.all() - model_form = forms.VLANCSVForm + model_form = forms.VLANImportForm table = tables.VLANTable @@ -1078,22 +1148,25 @@ class ServiceTemplateListView(generic.ObjectListView): table = tables.ServiceTemplateTable +@register_model_view(ServiceTemplate) class ServiceTemplateView(generic.ObjectView): queryset = ServiceTemplate.objects.all() +@register_model_view(ServiceTemplate, 'edit') class ServiceTemplateEditView(generic.ObjectEditView): queryset = ServiceTemplate.objects.all() form = forms.ServiceTemplateForm +@register_model_view(ServiceTemplate, 'delete') class ServiceTemplateDeleteView(generic.ObjectDeleteView): queryset = ServiceTemplate.objects.all() class ServiceTemplateBulkImportView(generic.BulkImportView): queryset = ServiceTemplate.objects.all() - model_form = forms.ServiceTemplateCSVForm + model_form = forms.ServiceTemplateImportForm table = tables.ServiceTemplateTable @@ -1121,6 +1194,7 @@ class ServiceListView(generic.ObjectListView): table = tables.ServiceTable +@register_model_view(Service) class ServiceView(generic.ObjectView): queryset = Service.objects.all() @@ -1131,19 +1205,21 @@ class ServiceCreateView(generic.ObjectEditView): template_name = 'ipam/service_create.html' +@register_model_view(Service, 'edit') class ServiceEditView(generic.ObjectEditView): queryset = Service.objects.all() form = forms.ServiceForm template_name = 'ipam/service_edit.html' +@register_model_view(Service, 'delete') class ServiceDeleteView(generic.ObjectDeleteView): queryset = Service.objects.all() class ServiceBulkImportView(generic.BulkImportView): queryset = Service.objects.all() - model_form = forms.ServiceCSVForm + model_form = forms.ServiceImportForm table = tables.ServiceTable @@ -1162,7 +1238,6 @@ class ServiceBulkDeleteView(generic.BulkDeleteView): # L2VPN - class L2VPNListView(generic.ObjectListView): queryset = L2VPN.objects.all() table = L2VPNTable @@ -1170,6 +1245,7 @@ class L2VPNListView(generic.ObjectListView): filterset_form = forms.L2VPNFilterForm +@register_model_view(L2VPN) class L2VPNView(generic.ObjectView): queryset = L2VPN.objects.all() @@ -1194,18 +1270,20 @@ class L2VPNView(generic.ObjectView): } +@register_model_view(L2VPN, 'edit') class L2VPNEditView(generic.ObjectEditView): queryset = L2VPN.objects.all() form = forms.L2VPNForm +@register_model_view(L2VPN, 'delete') class L2VPNDeleteView(generic.ObjectDeleteView): queryset = L2VPN.objects.all() class L2VPNBulkImportView(generic.BulkImportView): queryset = L2VPN.objects.all() - model_form = forms.L2VPNCSVForm + model_form = forms.L2VPNImportForm table = tables.L2VPNTable @@ -1222,6 +1300,10 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable +# +# L2VPN terminations +# + class L2VPNTerminationListView(generic.ObjectListView): queryset = L2VPNTermination.objects.all() table = L2VPNTerminationTable @@ -1229,23 +1311,26 @@ class L2VPNTerminationListView(generic.ObjectListView): filterset_form = forms.L2VPNTerminationFilterForm +@register_model_view(L2VPNTermination) class L2VPNTerminationView(generic.ObjectView): queryset = L2VPNTermination.objects.all() +@register_model_view(L2VPNTermination, 'edit') class L2VPNTerminationEditView(generic.ObjectEditView): queryset = L2VPNTermination.objects.all() form = forms.L2VPNTerminationForm template_name = 'ipam/l2vpntermination_edit.html' +@register_model_view(L2VPNTermination, 'delete') class L2VPNTerminationDeleteView(generic.ObjectDeleteView): queryset = L2VPNTermination.objects.all() class L2VPNTerminationBulkImportView(generic.BulkImportView): queryset = L2VPNTermination.objects.all() - model_form = forms.L2VPNTerminationCSVForm + model_form = forms.L2VPNTerminationImportForm table = tables.L2VPNTerminationTable diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index d6504282e..d7e226c04 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError -from django.shortcuts import get_object_or_404 +from django.http import Http404 from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -140,7 +140,9 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali # Overrides ListModelMixin to allow processing ExportTemplates. if 'export' in request.GET: content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() + if et is None: + raise Http404 queryset = self.filter_queryset(self.get_queryset()) return et.render_to_response(queryset) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index e2295888f..8efb0a033 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ class ConfigParam: @@ -18,9 +19,9 @@ PARAMS = ( # Banners ConfigParam( name='BANNER_LOGIN', - label='Login banner', + label=_('Login banner'), default='', - description="Additional content to display on the login page", + description=_("Additional content to display on the login page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -29,9 +30,9 @@ PARAMS = ( ), ConfigParam( name='BANNER_TOP', - label='Top banner', + label=_('Top banner'), default='', - description="Additional content to display at the top of every page", + description=_("Additional content to display at the top of every page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -40,9 +41,9 @@ PARAMS = ( ), ConfigParam( name='BANNER_BOTTOM', - label='Bottom banner', + label=_('Bottom banner'), default='', - description="Additional content to display at the bottom of every page", + description=_("Additional content to display at the bottom of every page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -53,69 +54,69 @@ PARAMS = ( # IPAM ConfigParam( name='ENFORCE_GLOBAL_UNIQUE', - label='Globally unique IP space', + label=_('Globally unique IP space'), default=False, - description="Enforce unique IP addressing within the global table", + description=_("Enforce unique IP addressing within the global table"), field=forms.BooleanField ), ConfigParam( name='PREFER_IPV4', - label='Prefer IPv4', + label=_('Prefer IPv4'), default=False, - description="Prefer IPv4 addresses over IPv6", + description=_("Prefer IPv4 addresses over IPv6"), field=forms.BooleanField ), # Racks ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', - label='Rack unit height', + label=_('Rack unit height'), default=22, - description="Default unit height for rendered rack elevations", + description=_("Default unit height for rendered rack elevations"), field=forms.IntegerField ), ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', - label='Rack unit width', + label=_('Rack unit width'), default=220, - description="Default unit width for rendered rack elevations", + description=_("Default unit width for rendered rack elevations"), field=forms.IntegerField ), # Power ConfigParam( name='POWERFEED_DEFAULT_VOLTAGE', - label='Powerfeed voltage', + label=_('Powerfeed voltage'), default=120, - description="Default voltage for powerfeeds", + description=_("Default voltage for powerfeeds"), field=forms.IntegerField ), ConfigParam( name='POWERFEED_DEFAULT_AMPERAGE', - label='Powerfeed amperage', + label=_('Powerfeed amperage'), default=15, - description="Default amperage for powerfeeds", + description=_("Default amperage for powerfeeds"), field=forms.IntegerField ), ConfigParam( name='POWERFEED_DEFAULT_MAX_UTILIZATION', - label='Powerfeed max utilization', + label=_('Powerfeed max utilization'), default=80, - description="Default max utilization for powerfeeds", + description=_("Default max utilization for powerfeeds"), field=forms.IntegerField ), # Security ConfigParam( name='ALLOWED_URL_SCHEMES', - label='Allowed URL schemes', + label=_('Allowed URL schemes'), default=( 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', ), - description="Permitted schemes for URLs in user-provided content", + description=_("Permitted schemes for URLs in user-provided content"), field=SimpleArrayField, field_kwargs={'base_field': forms.CharField()} ), @@ -123,13 +124,13 @@ PARAMS = ( # Pagination ConfigParam( name='PAGINATE_COUNT', - label='Default page size', + label=_('Default page size'), default=50, field=forms.IntegerField ), ConfigParam( name='MAX_PAGE_SIZE', - label='Maximum page size', + label=_('Maximum page size'), default=1000, field=forms.IntegerField ), @@ -137,9 +138,9 @@ PARAMS = ( # Validation ConfigParam( name='CUSTOM_VALIDATORS', - label='Custom validators', + label=_('Custom validators'), default={}, - description="Custom validation rules (JSON)", + description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ 'widget': forms.Textarea( @@ -151,28 +152,28 @@ PARAMS = ( # NAPALM ConfigParam( name='NAPALM_USERNAME', - label='NAPALM username', + label=_('NAPALM username'), default='', - description="Username to use when connecting to devices via NAPALM" + description=_("Username to use when connecting to devices via NAPALM") ), ConfigParam( name='NAPALM_PASSWORD', - label='NAPALM password', + label=_('NAPALM password'), default='', - description="Password to use when connecting to devices via NAPALM" + description=_("Password to use when connecting to devices via NAPALM") ), ConfigParam( name='NAPALM_TIMEOUT', - label='NAPALM timeout', + label=_('NAPALM timeout'), default=30, - description="NAPALM connection timeout (in seconds)", + description=_("NAPALM connection timeout (in seconds)"), field=forms.IntegerField ), ConfigParam( name='NAPALM_ARGS', - label='NAPALM arguments', + label=_('NAPALM arguments'), default={}, - description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", + description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"), field=forms.JSONField, field_kwargs={ 'widget': forms.Textarea( @@ -184,46 +185,46 @@ PARAMS = ( # User preferences ConfigParam( name='DEFAULT_USER_PREFERENCES', - label='Default preferences', + label=_('Default preferences'), default={}, - description="Default preferences for new users", + description=_("Default preferences for new users"), field=forms.JSONField ), # Miscellaneous ConfigParam( name='MAINTENANCE_MODE', - label='Maintenance mode', + label=_('Maintenance mode'), default=False, - description="Enable maintenance mode", + description=_("Enable maintenance mode"), field=forms.BooleanField ), ConfigParam( name='GRAPHQL_ENABLED', - label='GraphQL enabled', + label=_('GraphQL enabled'), default=True, - description="Enable the GraphQL API", + description=_("Enable the GraphQL API"), field=forms.BooleanField ), ConfigParam( name='CHANGELOG_RETENTION', - label='Changelog retention', + label=_('Changelog retention'), default=90, - description="Days to retain changelog history (set to zero for unlimited)", + description=_("Days to retain changelog history (set to zero for unlimited)"), field=forms.IntegerField ), ConfigParam( name='JOBRESULT_RETENTION', - label='Job result retention', + label=_('Job result retention'), default=90, - description="Days to retain job result history (set to zero for unlimited)", + description=_("Days to retain job result history (set to zero for unlimited)"), field=forms.IntegerField ), ConfigParam( name='MAPS_URL', - label='Maps URL', + label=_('Maps URL'), default='https://maps.google.com/?q=', - description="Base URL for mapping geographic locations" + description=_("Base URL for mapping geographic locations") ), ) diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 2601775e6..5e057d54a 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -72,6 +72,9 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] +# Permit the retrieval of API tokens after their creation. +ALLOW_TOKEN_RETRIEVAL = False + # Enable any desired validators for local account passwords below. For a list of included validators, please see the # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation. AUTH_PASSWORD_VALIDATORS = [ diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 776938a97..0889f6a5c 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,5 +1,7 @@ # Prefix for nested serializers NESTED_SERIALIZER_PREFIX = 'Nested' -# Max results per object type -SEARCH_MAX_RESULTS = 15 +# RQ queue names +RQ_QUEUE_DEFAULT = 'default' +RQ_QUEUE_HIGH = 'high' +RQ_QUEUE_LOW = 'low' diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index 74178ceb4..024ca85b5 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings -from extras.registry import registry from netbox.config import get_config +from netbox.registry import registry def settings_and_registry(request): diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py index cd4a869d2..a94f83e18 100644 --- a/netbox/netbox/denormalized.py +++ b/netbox/netbox/denormalized.py @@ -3,7 +3,7 @@ import logging from django.db.models.signals import post_save from django.dispatch import receiver -from extras.registry import registry +from netbox.registry import registry logger = logging.getLogger('netbox.denormalized') diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b6776e3c1..ee0ab330c 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -2,12 +2,14 @@ import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field +from django.utils.translation import gettext as _ from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter -from extras.models import CustomField +from extras.models import CustomField, SavedFilter from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP @@ -46,7 +48,7 @@ class BaseFilterSet(django_filters.FilterSet): 'filter_class': filters.MultiValueDateTimeFilter }, models.DecimalField: { - 'filter_class': filters.MultiValueNumberFilter + 'filter_class': filters.MultiValueDecimalFilter }, models.EmailField: { 'filter_class': filters.MultiValueCharFilter @@ -80,12 +82,31 @@ class BaseFilterSet(django_filters.FilterSet): }, }) - def __init__(self, *args, **kwargs): + def __init__(self, data=None, *args, **kwargs): # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # however FilterSet Factory is setup before this which creates the # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() - super().__init__(*args, **kwargs) + + # Apply any referenced SavedFilters + if data and ('filter' in data or 'filter_id' in data): + data = data.copy() # Get a mutable copy + saved_filters = SavedFilter.objects.filter( + Q(slug__in=data.pop('filter', [])) | + Q(pk__in=data.pop('filter_id', [])) + ) + for sf in saved_filters: + for key, value in sf.parameters.items(): + # QueryDicts are... fun + if type(value) not in (list, tuple): + value = [value] + if key in data: + for v in value: + data.appendlist(key, v) + else: + data.setlist(key, value) + + super().__init__(data, *args, **kwargs) @staticmethod def _get_filter_lookup_dict(existing_filter): @@ -95,6 +116,7 @@ class BaseFilterSet(django_filters.FilterSet): filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, + filters.MultiValueDecimalFilter, filters.MultiValueTimeFilter )): return FILTER_NUMERIC_BASED_LOOKUP_MAP @@ -217,7 +239,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): """ q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) tag = TagFilter() diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index d1451e003..d8acef94c 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,45 +1,59 @@ -from django import forms +import re + +from django import forms +from django.utils.translation import gettext as _ + +from netbox.search import LookupTypes +from netbox.search.backends import search_backend +from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple -from netbox.search import SEARCH_TYPE_HIERARCHY -from utilities.forms import BootstrapMixin from .base import * - -def build_search_choices(): - result = list() - result.append(('', 'All Objects')) - for category, items in SEARCH_TYPE_HIERARCHY.items(): - subcategories = list() - for slug, obj in items.items(): - name = obj['queryset'].model._meta.verbose_name_plural - name = name[0].upper() + name[1:] - subcategories.append((slug, name)) - result.append((category, tuple(subcategories))) - - return tuple(result) - - -OBJ_TYPE_CHOICES = build_search_choices() - - -def build_options(): - options = [{"label": OBJ_TYPE_CHOICES[0][1], "items": []}] - - for label, choices in OBJ_TYPE_CHOICES[1:]: - items = [] - - for value, choice_label in choices: - items.append({"label": choice_label, "value": value}) - - options.append({"label": label, "items": items}) - return options +LOOKUP_CHOICES = ( + ('', _('Partial match')), + (LookupTypes.EXACT, _('Exact match')), + (LookupTypes.STARTSWITH, _('Starts with')), + (LookupTypes.ENDSWITH, _('Ends with')), + (LookupTypes.REGEX, _('Regex')), +) class SearchForm(BootstrapMixin, forms.Form): q = forms.CharField( - label='Search' + label=_('Search'), + widget=forms.TextInput( + attrs={ + 'hx-get': '', + 'hx-target': '#object_list', + 'hx-trigger': 'keyup[target.value.length >= 3] changed delay:500ms', + } + ) ) - obj_type = forms.ChoiceField( - choices=OBJ_TYPE_CHOICES, required=False, label='Type' + obj_types = forms.MultipleChoiceField( + choices=[], + required=False, + label=_('Object type(s)'), + widget=StaticSelectMultiple() ) - options = build_options() + lookup = forms.ChoiceField( + choices=LOOKUP_CHOICES, + initial=LookupTypes.PARTIAL, + required=False, + widget=StaticSelect() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['obj_types'].choices = search_backend.get_object_types() + + def clean(self): + + # Validate regular expressions + if self.cleaned_data['lookup'] == LookupTypes.REGEX: + try: + re.compile(self.cleaned_data['q']) + except re.error as e: + raise forms.ValidationError({ + 'q': f'Invalid regular expression: {e}' + }) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2cbc67971..d69445e78 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -1,16 +1,18 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.utils.translation import gettext as _ from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices -from extras.forms.customfields import CustomFieldsMixin +from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm -from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField __all__ = ( - 'NetBoxModelForm', 'NetBoxModelCSVForm', + 'NetBoxModelForm', + 'NetBoxModelImportForm', 'NetBoxModelBulkEditForm', 'NetBoxModelFilterSetForm', ) @@ -45,6 +47,9 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): # Save custom field data on instance for cf_name, customfield in self.custom_fields.items(): + if cf_name not in self.fields: + # Custom fields may be absent when performing bulk updates via import + continue key = cf_name[3:] # Strip "cf_" from field name value = self.cleaned_data.get(cf_name) @@ -57,11 +62,20 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): return super().clean() -class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): +class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ - tags = None # Temporary fix in lieu of tag import support (see #9158) + id = forms.IntegerField( + required=False, + help_text='Numeric ID of an existing object to update (if not creating a new object)' + ) + tags = CSVModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + to_field_name='slug', + help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")' + ) def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).filter( @@ -72,6 +86,14 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) +class NetBoxModelCSVForm(NetBoxModelImportForm): + """ + Maintains backward compatibility for NetBoxModelImportForm for plugins. + """ + # TODO: Remove in NetBox v3.5 + pass + + class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom @@ -114,7 +136,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) -class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): +class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form): """ Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the corresponding FilterSet *must* provide a `q` filter. @@ -126,9 +148,18 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ q = forms.CharField( required=False, - label='Search' + label=_('Search') ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit saved filters to those applicable to the form's model + content_type = ContentType.objects.get_for_model(self.model) + self.fields['filter_id'].widget.add_query_params({ + 'content_type_id': content_type.pk, + }) + def _get_custom_fields(self, content_type): return super()._get_custom_fields(content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py index 0ad25a541..bd8e3cb88 100644 --- a/netbox/netbox/graphql/__init__.py +++ b/netbox/netbox/graphql/__init__.py @@ -1,9 +1,13 @@ import graphene -from graphene_django.converter import convert_django_field +from dcim.fields import MACAddressField, WWNField +from django.db import models +from graphene import Dynamic +from graphene_django.converter import convert_django_field, get_django_field_description +from graphene_django.fields import DjangoConnectionField +from ipam.fields import IPAddressField, IPNetworkField from taggit.managers import TaggableManager -from dcim.fields import MACAddressField, WWNField -from ipam.fields import IPAddressField, IPNetworkField +from .fields import ObjectListField @convert_django_field.register(TaggableManager) @@ -21,3 +25,45 @@ def convert_field_to_tags_list(field, registry=None): def convert_field_to_string(field, registry=None): # TODO: Update to use get_django_field_description under django_graphene v3.0 return graphene.String(description=field.help_text, required=not field.null) + + +@convert_django_field.register(models.ManyToManyField) +@convert_django_field.register(models.ManyToManyRel) +@convert_django_field.register(models.ManyToOneRel) +def convert_field_to_list_or_connection(field, registry=None): + """ + From graphene_django.converter.py we need to monkey-patch this to return + our ObjectListField with filtering support instead of DjangoListField + """ + model = field.related_model + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + if isinstance(field, models.ManyToManyField): + description = get_django_field_description(field) + else: + description = get_django_field_description(field.field) + + # If there is a connection, we should transform the field + # into a DjangoConnectionField + if _type._meta.connection: + # Use a DjangoFilterConnectionField if there are + # defined filter_fields or a filterset_class in the + # DjangoObjectType Meta + if _type._meta.filter_fields or _type._meta.filterset_class: + from .filter.fields import DjangoFilterConnectionField + + return DjangoFilterConnectionField(_type, required=True, description=description) + + return DjangoConnectionField(_type, required=True, description=description) + + return ObjectListField( + _type, + required=True, # A Set is always returned, never None. + description=description, + ) + + return Dynamic(dynamic_type) diff --git a/netbox/netbox/graphql/fields.py b/netbox/netbox/graphql/fields.py index 1f53792a3..5b8e6cc5b 100644 --- a/netbox/netbox/graphql/fields.py +++ b/netbox/netbox/graphql/fields.py @@ -54,8 +54,7 @@ class ObjectListField(DjangoListField): @staticmethod def list_resolver(django_object_type, resolver, default_manager, root, info, **args): - # Get the QuerySet from the object type - queryset = django_object_type.get_queryset(default_manager, info) + queryset = super(ObjectListField, ObjectListField).list_resolver(django_object_type, resolver, default_manager, root, info, **args) # Instantiate and apply the FilterSet, if defined filterset_class = django_object_type._meta.filterset_class diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py index 7d14189dd..8fc186b4d 100644 --- a/netbox/netbox/graphql/scalars.py +++ b/netbox/netbox/graphql/scalars.py @@ -1,6 +1,6 @@ from graphene import Scalar from graphql.language import ast -from graphql.type.scalars import MAX_INT, MIN_INT +from graphene.types.scalars import MAX_INT, MIN_INT class BigInt(Scalar): diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index f0bc8559c..82abfb4d5 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -3,8 +3,8 @@ import graphene from circuits.graphql.schema import CircuitsQuery from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery -from extras.registry import registry from ipam.graphql.schema import IPAMQuery +from netbox.registry import registry from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery @@ -12,12 +12,12 @@ from wireless.graphql.schema import WirelessQuery class Query( + UsersQuery, CircuitsQuery, DCIMQuery, ExtrasQuery, IPAMQuery, TenancyQuery, - UsersQuery, VirtualizationQuery, WirelessQuery, *registry['plugins']['graphql_schemas'], # Append plugin schemas diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 7d1b26f84..10847742b 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,7 +1,13 @@ -from django.contrib.contenttypes.models import ContentType -from graphene_django import DjangoObjectType +import graphene -from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, JournalEntriesMixin, TagsMixin +from django.contrib.contenttypes.models import ContentType +from extras.graphql.mixins import ( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, +) +from graphene_django import DjangoObjectType __all__ = ( 'BaseObjectType', @@ -19,6 +25,9 @@ class BaseObjectType(DjangoObjectType): """ Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. """ + display = graphene.String() + class_type = graphene.String() + class Meta: abstract = True @@ -27,6 +36,12 @@ class BaseObjectType(DjangoObjectType): # Enforce object permissions on the queryset return queryset.restrict(info.context.user, 'view') + def resolve_display(parent, info, **kwargs): + return str(parent) + + def resolve_class_type(parent, info, **kwargs): + return parent.__class__.__name__ + class ObjectType( ChangelogMixin, diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 5c4b2813d..edf88a234 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -11,7 +11,7 @@ from django.http import Http404, HttpResponseRedirect from extras.context_managers import change_logging from netbox.config import clear_config -from netbox.views import server_error +from netbox.views import handler_500 from utilities.api import is_api_request, rest_api_server_error @@ -201,4 +201,4 @@ class ExceptionHandlingMiddleware: # Return a custom error message, or fall back to Django's default 500 error handling if custom_template: - return server_error(request, template_name=custom_template) + return handler_500(request, template_name=custom_template) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 1385dd585..d3f3e78bc 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,16 +1,18 @@ +from django.conf import settings from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from netbox.models.features import * from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet -from netbox.models.features import * __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', - 'OrganizationalModel', 'NetBoxModel', + 'OrganizationalModel', + 'PrimaryModel', ) @@ -20,19 +22,16 @@ class NetBoxFeatureSet( CustomLinksMixin, CustomValidationMixin, ExportTemplatesMixin, + JournalingMixin, TagsMixin, WebhooksMixin ): class Meta: abstract = True - @classmethod - def get_prerequisite_models(cls): - """ - Return a list of model types that are required to create this model or empty list if none. This is used for - showing prerequisite warnings in the UI on the list and detail views. - """ - return [] + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' # @@ -50,11 +49,27 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model): +class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): + """ + Base model for most object types. Suitable for use by plugins. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class PrimaryModel(NetBoxModel): """ Primary models represent real objects within the infrastructure being modeled. """ - objects = RestrictedQuerySet.as_manager() + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) class Meta: abstract = True @@ -76,6 +91,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): name = models.CharField( max_length=100 ) + slug = models.SlugField( + max_length=100 + ) description = models.CharField( max_length=200, blank=True @@ -129,3 +147,6 @@ class OrganizationalModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True ordering = ('name',) + + def __str__(self): + return self.name diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7f30248b4..8e5af0ab5 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,10 +1,10 @@ from collections import defaultdict +from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager @@ -12,7 +12,9 @@ from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import is_taggable, register_features from netbox.signals import post_clean +from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object +from utilities.views import register_model_view __all__ = ( 'ChangeLoggingMixin', @@ -92,8 +94,17 @@ class CloningMixin(models.Model): def clone(self): """ - Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- - populating an object creation form in the UI. + Returns a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. By default, this method will replicate any fields listed in the + model's `clone_fields` list (if defined), but it can be overridden to apply custom logic. + + ```python + class MyModel(NetBoxModel): + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs + ``` """ attrs = {} @@ -115,7 +126,7 @@ class CustomFieldsMixin(models.Model): Enables support for custom fields. """ custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, + encoder=CustomFieldJSONEncoder, blank=True, default=dict ) @@ -123,18 +134,35 @@ class CustomFieldsMixin(models.Model): class Meta: abstract = True - @property + @cached_property def cf(self): """ - A pass-through convenience alias for accessing `custom_field_data` (read-only). + Return a dictionary mapping each custom field for this instance to its deserialized value. ```python >>> tenant = Tenant.objects.first() >>> tenant.cf - {'cust_id': 'CYB01'} + {'primary_site': , 'cust_id': 'DMI01', 'is_active': True} ``` """ - return self.custom_field_data + return { + cf.name: cf.deserialize(self.custom_field_data.get(cf.name)) + for cf in self.custom_fields + } + + @cached_property + def custom_fields(self): + """ + Return the QuerySet of CustomFields assigned to this model. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.custom_fields + , , ]> + ``` + """ + from extras.models import CustomField + return CustomField.objects.get_for_model(self) def get_custom_fields(self, omit_hidden=False): """ @@ -145,10 +173,13 @@ class CustomFieldsMixin(models.Model): >>> tenant.get_custom_fields() {: 'CYB01'} ``` + + Args: + omit_hidden: If True, custom fields with no UI visibility will be omitted. """ from extras.models import CustomField - data = {} + for field in CustomField.objects.get_for_model(self): # Skip fields that are hidden if 'omit_hidden' is set if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: @@ -162,12 +193,28 @@ class CustomFieldsMixin(models.Model): def get_custom_fields_by_group(self): """ Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. - """ - grouped_custom_fields = defaultdict(dict) - for cf, value in self.get_custom_fields(omit_hidden=True).items(): - grouped_custom_fields[cf.group_name][cf] = value - return dict(grouped_custom_fields) + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields_by_group() + { + '': {: }, + 'Billing': {: 'DMI01', : True} + } + ``` + """ + from extras.models import CustomField + groups = defaultdict(dict) + visible_custom_fields = CustomField.objects.get_for_model(self).exclude( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ) + + for cf in visible_custom_fields: + value = self.custom_field_data.get(cf.name) + value = cf.deserialize(value) + groups[cf.group_name][cf] = value + + return dict(groups) def clean(self): super().clean() @@ -283,3 +330,17 @@ def _register_features(sender, **kwargs): feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) } register_features(sender, features) + + # Feature view registration + if issubclass(sender, JournalingMixin): + register_model_view( + sender, + 'journal', + kwargs={'model': sender} + )('netbox.views.generic.ObjectJournalView') + if issubclass(sender, ChangeLoggingMixin): + register_model_view( + sender, + 'changelog', + kwargs={'model': sender} + )('netbox.views.generic.ObjectChangeLogView') diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py new file mode 100644 index 000000000..a05b1c495 --- /dev/null +++ b/netbox/netbox/navigation/__init__.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from typing import Sequence, Optional + +from utilities.choices import ButtonColorChoices + + +__all__ = ( + 'get_model_item', + 'get_model_buttons', + 'Menu', + 'MenuGroup', + 'MenuItem', + 'MenuItemButton', +) + + +# +# Navigation menu data classes +# + +@dataclass +class MenuItemButton: + + link: str + title: str + icon_class: str + permissions: Optional[Sequence[str]] = () + color: Optional[str] = None + + +@dataclass +class MenuItem: + + link: str + link_text: str + permissions: Optional[Sequence[str]] = () + buttons: Optional[Sequence[MenuItemButton]] = () + + +@dataclass +class MenuGroup: + + label: str + items: Sequence[MenuItem] + + +@dataclass +class Menu: + + label: str + icon_class: str + groups: Sequence[MenuGroup] + + @property + def name(self): + return self.label.replace(' ', '_') + + +# +# Utility functions +# + +def get_model_item(app_label, model_name, label, actions=('add', 'import')): + return MenuItem( + link=f'{app_label}:{model_name}_list', + link_text=label, + permissions=[f'{app_label}.view_{model_name}'], + buttons=get_model_buttons(app_label, model_name, actions) + ) + + +def get_model_buttons(app_label, model_name, actions=('add', 'import')): + buttons = [] + + if 'add' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.GREEN + ) + ) + if 'import' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.CYAN + ) + ) + + return buttons diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py new file mode 100644 index 000000000..09a35489d --- /dev/null +++ b/netbox/netbox/navigation/menu.py @@ -0,0 +1,350 @@ +from django.utils.translation import gettext as _ + +from netbox.registry import registry +from . import * + +# +# Nav menus +# + +ORGANIZATION_MENU = Menu( + label=_('Organization'), + icon_class='mdi mdi-domain', + groups=( + MenuGroup( + label=_('Sites'), + items=( + get_model_item('dcim', 'site', _('Sites')), + get_model_item('dcim', 'region', _('Regions')), + get_model_item('dcim', 'sitegroup', _('Site Groups')), + get_model_item('dcim', 'location', _('Locations')), + ), + ), + MenuGroup( + label=_('Racks'), + items=( + get_model_item('dcim', 'rack', _('Racks')), + get_model_item('dcim', 'rackrole', _('Rack Roles')), + get_model_item('dcim', 'rackreservation', _('Reservations')), + MenuItem( + link='dcim:rack_elevation_list', + link_text=_('Elevations'), + permissions=['dcim.view_rack'] + ), + ), + ), + MenuGroup( + label=_('Tenancy'), + items=( + get_model_item('tenancy', 'tenant', _('Tenants')), + get_model_item('tenancy', 'tenantgroup', _('Tenant Groups')), + ), + ), + MenuGroup( + label=_('Contacts'), + items=( + get_model_item('tenancy', 'contact', _('Contacts')), + get_model_item('tenancy', 'contactgroup', _('Contact Groups')), + get_model_item('tenancy', 'contactrole', _('Contact Roles')), + ), + ), + ), +) + +DEVICES_MENU = Menu( + label=_('Devices'), + icon_class='mdi mdi-server', + groups=( + MenuGroup( + label=_('Devices'), + items=( + get_model_item('dcim', 'device', _('Devices')), + get_model_item('dcim', 'module', _('Modules')), + get_model_item('dcim', 'devicerole', _('Device Roles')), + get_model_item('dcim', 'platform', _('Platforms')), + get_model_item('dcim', 'virtualchassis', _('Virtual Chassis')), + get_model_item('dcim', 'virtualdevicecontext', _('Virtual Device Contexts')), + ), + ), + MenuGroup( + label=_('Device Types'), + items=( + get_model_item('dcim', 'devicetype', _('Device Types')), + get_model_item('dcim', 'moduletype', _('Module Types')), + get_model_item('dcim', 'manufacturer', _('Manufacturers')), + ), + ), + MenuGroup( + label=_('Device Components'), + items=( + get_model_item('dcim', 'interface', _('Interfaces'), actions=['import']), + get_model_item('dcim', 'frontport', _('Front Ports'), actions=['import']), + get_model_item('dcim', 'rearport', _('Rear Ports'), actions=['import']), + get_model_item('dcim', 'consoleport', _('Console Ports'), actions=['import']), + get_model_item('dcim', 'consoleserverport', _('Console Server Ports'), actions=['import']), + get_model_item('dcim', 'powerport', _('Power Ports'), actions=['import']), + get_model_item('dcim', 'poweroutlet', _('Power Outlets'), actions=['import']), + get_model_item('dcim', 'modulebay', _('Module Bays'), actions=['import']), + get_model_item('dcim', 'devicebay', _('Device Bays'), actions=['import']), + get_model_item('dcim', 'inventoryitem', _('Inventory Items'), actions=['import']), + get_model_item('dcim', 'inventoryitemrole', _('Inventory Item Roles')), + ), + ), + ), +) + +CONNECTIONS_MENU = Menu( + label=_('Connections'), + icon_class='mdi mdi-connection', + groups=( + MenuGroup( + label=_('Connections'), + items=( + get_model_item('dcim', 'cable', _('Cables'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), + MenuItem( + link='dcim:interface_connections_list', + link_text=_('Interface Connections'), + permissions=['dcim.view_interface'] + ), + MenuItem( + link='dcim:console_connections_list', + link_text=_('Console Connections'), + permissions=['dcim.view_consoleport'] + ), + MenuItem( + link='dcim:power_connections_list', + link_text=_('Power Connections'), + permissions=['dcim.view_powerport'] + ), + ), + ), + ), +) + +WIRELESS_MENU = Menu( + label=_('Wireless'), + icon_class='mdi mdi-wifi', + groups=( + MenuGroup( + label=_('Wireless'), + items=( + get_model_item('wireless', 'wirelesslan', _('Wireless LANs')), + get_model_item('wireless', 'wirelesslangroup', _('Wireless LAN Groups')), + ), + ), + ), +) + +IPAM_MENU = Menu( + label=_('IPAM'), + icon_class='mdi mdi-counter', + groups=( + MenuGroup( + label=_('IP Addresses'), + items=( + get_model_item('ipam', 'ipaddress', _('IP Addresses')), + get_model_item('ipam', 'iprange', _('IP Ranges')), + ), + ), + MenuGroup( + label=_('Prefixes'), + items=( + get_model_item('ipam', 'prefix', _('Prefixes')), + get_model_item('ipam', 'role', _('Prefix & VLAN Roles')), + ), + ), + MenuGroup( + label=_('ASNs'), + items=( + get_model_item('ipam', 'asn', _('ASNs')), + ), + ), + MenuGroup( + label=_('Aggregates'), + items=( + get_model_item('ipam', 'aggregate', _('Aggregates')), + get_model_item('ipam', 'rir', _('RIRs')), + ), + ), + MenuGroup( + label=_('VRFs'), + items=( + get_model_item('ipam', 'vrf', _('VRFs')), + get_model_item('ipam', 'routetarget', _('Route Targets')), + ), + ), + MenuGroup( + label=_('VLANs'), + items=( + get_model_item('ipam', 'vlan', _('VLANs')), + get_model_item('ipam', 'vlangroup', _('VLAN Groups')), + ), + ), + MenuGroup( + label=_('Other'), + items=( + get_model_item('ipam', 'fhrpgroup', _('FHRP Groups')), + get_model_item('ipam', 'servicetemplate', _('Service Templates')), + get_model_item('ipam', 'service', _('Services')), + ), + ), + ), +) + +OVERLAY_MENU = Menu( + label=_('Overlay'), + icon_class='mdi mdi-graph-outline', + groups=( + MenuGroup( + label='L2VPNs', + items=( + get_model_item('ipam', 'l2vpn', _('L2VPNs')), + get_model_item('ipam', 'l2vpntermination', _('Terminations')), + ), + ), + ), +) + +VIRTUALIZATION_MENU = Menu( + label=_('Virtualization'), + icon_class='mdi mdi-monitor', + groups=( + MenuGroup( + label=_('Virtual Machines'), + items=( + get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), + get_model_item('virtualization', 'vminterface', _('Interfaces'), actions=['import']), + ), + ), + MenuGroup( + label=_('Clusters'), + items=( + get_model_item('virtualization', 'cluster', _('Clusters')), + get_model_item('virtualization', 'clustertype', _('Cluster Types')), + get_model_item('virtualization', 'clustergroup', _('Cluster Groups')), + ), + ), + ), +) + +CIRCUITS_MENU = Menu( + label=_('Circuits'), + icon_class='mdi mdi-transit-connection-variant', + groups=( + MenuGroup( + label=_('Circuits'), + items=( + get_model_item('circuits', 'circuit', _('Circuits')), + get_model_item('circuits', 'circuittype', _('Circuit Types')), + ), + ), + MenuGroup( + label=_('Providers'), + items=( + get_model_item('circuits', 'provider', _('Providers')), + get_model_item('circuits', 'providernetwork', _('Provider Networks')), + ), + ), + ), +) + +POWER_MENU = Menu( + label=_('Power'), + icon_class='mdi mdi-flash', + groups=( + MenuGroup( + label=_('Power'), + items=( + get_model_item('dcim', 'powerfeed', _('Power Feeds')), + get_model_item('dcim', 'powerpanel', _('Power Panels')), + ), + ), + ), +) + +OTHER_MENU = Menu( + label=_('Other'), + icon_class='mdi mdi-notification-clear-all', + groups=( + MenuGroup( + label=_('Logging'), + items=( + get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]), + get_model_item('extras', 'objectchange', _('Change Log'), actions=[]), + ), + ), + MenuGroup( + label=_('Customization'), + items=( + get_model_item('extras', 'customfield', _('Custom Fields')), + get_model_item('extras', 'customlink', _('Custom Links')), + get_model_item('extras', 'exporttemplate', _('Export Templates')), + get_model_item('extras', 'savedfilter', _('Saved Filters')), + ), + ), + MenuGroup( + label=_('Integrations'), + items=( + get_model_item('extras', 'webhook', _('Webhooks')), + MenuItem( + link='extras:report_list', + link_text=_('Reports'), + permissions=['extras.view_report'] + ), + MenuItem( + link='extras:script_list', + link_text=_('Scripts'), + permissions=['extras.view_script'] + ), + MenuItem( + link='extras:jobresult_list', + link_text=_('Jobs'), + permissions=['extras.view_jobresult'], + ), + ), + ), + MenuGroup( + label=_('Other'), + items=( + get_model_item('extras', 'tag', 'Tags'), + get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), + ), + ), + ), +) + + +MENUS = [ + ORGANIZATION_MENU, + DEVICES_MENU, + CONNECTIONS_MENU, + WIRELESS_MENU, + IPAM_MENU, + OVERLAY_MENU, + VIRTUALIZATION_MENU, + CIRCUITS_MENU, + POWER_MENU, + OTHER_MENU, +] + +# +# Add plugin menus +# + +for menu in registry['plugins']['menus']: + MENUS.append(menu) + +if registry['plugins']['menu_items']: + + # Build the default plugins menu + groups = [ + MenuGroup(label=label, items=items) + for label, items in registry['plugins']['menu_items'].items() + ] + plugins_menu = Menu( + label=_("Plugins"), + icon_class="mdi mdi-puzzle", + groups=groups + ) + MENUS.append(plugins_menu) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py deleted file mode 100644 index a495f17c9..000000000 --- a/netbox/netbox/navigation_menu.py +++ /dev/null @@ -1,425 +0,0 @@ -from dataclasses import dataclass -from typing import Sequence, Optional - -from extras.registry import registry -from utilities.choices import ButtonColorChoices - - -# -# Nav menu data classes -# - -@dataclass -class MenuItemButton: - - link: str - title: str - icon_class: str - permissions: Optional[Sequence[str]] = () - color: Optional[str] = None - - -@dataclass -class MenuItem: - - link: str - link_text: str - permissions: Optional[Sequence[str]] = () - buttons: Optional[Sequence[MenuItemButton]] = () - - -@dataclass -class MenuGroup: - - label: str - items: Sequence[MenuItem] - - -@dataclass -class Menu: - - label: str - icon_class: str - groups: Sequence[MenuGroup] - - -# -# Utility functions -# - -def get_model_item(app_label, model_name, label, actions=('add', 'import')): - return MenuItem( - link=f'{app_label}:{model_name}_list', - link_text=label, - permissions=[f'{app_label}.view_{model_name}'], - buttons=get_model_buttons(app_label, model_name, actions) - ) - - -def get_model_buttons(app_label, model_name, actions=('add', 'import')): - buttons = [] - - if 'add' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_add', - title='Add', - icon_class='mdi mdi-plus-thick', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.GREEN - ) - ) - if 'import' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_import', - title='Import', - icon_class='mdi mdi-upload', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.CYAN - ) - ) - - return buttons - - -# -# Nav menus -# - -ORGANIZATION_MENU = Menu( - label='Organization', - icon_class='mdi mdi-domain', - groups=( - MenuGroup( - label='Sites', - items=( - get_model_item('dcim', 'site', 'Sites'), - get_model_item('dcim', 'region', 'Regions'), - get_model_item('dcim', 'sitegroup', 'Site Groups'), - get_model_item('dcim', 'location', 'Locations'), - ), - ), - MenuGroup( - label='Racks', - items=( - get_model_item('dcim', 'rack', 'Racks'), - get_model_item('dcim', 'rackrole', 'Rack Roles'), - get_model_item('dcim', 'rackreservation', 'Reservations'), - MenuItem( - link='dcim:rack_elevation_list', - link_text='Elevations', - permissions=['dcim.view_rack'] - ), - ), - ), - MenuGroup( - label='Tenancy', - items=( - get_model_item('tenancy', 'tenant', 'Tenants'), - get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'), - ), - ), - MenuGroup( - label='Contacts', - items=( - get_model_item('tenancy', 'contact', 'Contacts'), - get_model_item('tenancy', 'contactgroup', 'Contact Groups'), - get_model_item('tenancy', 'contactrole', 'Contact Roles'), - ), - ), - ), -) - -DEVICES_MENU = Menu( - label='Devices', - icon_class='mdi mdi-server', - groups=( - MenuGroup( - label='Devices', - items=( - get_model_item('dcim', 'device', 'Devices'), - get_model_item('dcim', 'module', 'Modules'), - get_model_item('dcim', 'devicerole', 'Device Roles'), - get_model_item('dcim', 'platform', 'Platforms'), - get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), - ), - ), - MenuGroup( - label='Device Types', - items=( - get_model_item('dcim', 'devicetype', 'Device Types'), - get_model_item('dcim', 'moduletype', 'Module Types'), - get_model_item('dcim', 'manufacturer', 'Manufacturers'), - ), - ), - MenuGroup( - label='Device Components', - items=( - get_model_item('dcim', 'interface', 'Interfaces', actions=['import']), - get_model_item('dcim', 'frontport', 'Front Ports', actions=['import']), - get_model_item('dcim', 'rearport', 'Rear Ports', actions=['import']), - get_model_item('dcim', 'consoleport', 'Console Ports', actions=['import']), - get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']), - get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']), - get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']), - get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']), - get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']), - get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']), - get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'), - ), - ), - ), -) - -CONNECTIONS_MENU = Menu( - label='Connections', - icon_class='mdi mdi-connection', - groups=( - MenuGroup( - label='Connections', - items=( - get_model_item('dcim', 'cable', 'Cables', actions=['import']), - get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']), - MenuItem( - link='dcim:interface_connections_list', - link_text='Interface Connections', - permissions=['dcim.view_interface'] - ), - MenuItem( - link='dcim:console_connections_list', - link_text='Console Connections', - permissions=['dcim.view_consoleport'] - ), - MenuItem( - link='dcim:power_connections_list', - link_text='Power Connections', - permissions=['dcim.view_powerport'] - ), - ), - ), - ), -) - -WIRELESS_MENU = Menu( - label='Wireless', - icon_class='mdi mdi-wifi', - groups=( - MenuGroup( - label='Wireless', - items=( - get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), - get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'), - ), - ), - ), -) - -IPAM_MENU = Menu( - label='IPAM', - icon_class='mdi mdi-counter', - groups=( - MenuGroup( - label='IP Addresses', - items=( - get_model_item('ipam', 'ipaddress', 'IP Addresses'), - get_model_item('ipam', 'iprange', 'IP Ranges'), - ), - ), - MenuGroup( - label='Prefixes', - items=( - get_model_item('ipam', 'prefix', 'Prefixes'), - get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), - ), - ), - MenuGroup( - label='ASNs', - items=( - get_model_item('ipam', 'asn', 'ASNs'), - ), - ), - MenuGroup( - label='Aggregates', - items=( - get_model_item('ipam', 'aggregate', 'Aggregates'), - get_model_item('ipam', 'rir', 'RIRs'), - ), - ), - MenuGroup( - label='VRFs', - items=( - get_model_item('ipam', 'vrf', 'VRFs'), - get_model_item('ipam', 'routetarget', 'Route Targets'), - ), - ), - MenuGroup( - label='VLANs', - items=( - get_model_item('ipam', 'vlan', 'VLANs'), - get_model_item('ipam', 'vlangroup', 'VLAN Groups'), - ), - ), - MenuGroup( - label='Other', - items=( - get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), - get_model_item('ipam', 'servicetemplate', 'Service Templates'), - get_model_item('ipam', 'service', 'Services'), - ), - ), - ), -) - -OVERLAY_MENU = Menu( - label='Overlay', - icon_class='mdi mdi-graph-outline', - groups=( - MenuGroup( - label='L2VPNs', - items=( - get_model_item('ipam', 'l2vpn', 'L2VPNs'), - get_model_item('ipam', 'l2vpntermination', 'Terminations'), - ), - ), - ), -) - -VIRTUALIZATION_MENU = Menu( - label='Virtualization', - icon_class='mdi mdi-monitor', - groups=( - MenuGroup( - label='Virtual Machines', - items=( - get_model_item('virtualization', 'virtualmachine', 'Virtual Machines'), - get_model_item('virtualization', 'vminterface', 'Interfaces', actions=['import']), - ), - ), - MenuGroup( - label='Clusters', - items=( - get_model_item('virtualization', 'cluster', 'Clusters'), - get_model_item('virtualization', 'clustertype', 'Cluster Types'), - get_model_item('virtualization', 'clustergroup', 'Cluster Groups'), - ), - ), - ), -) - -CIRCUITS_MENU = Menu( - label='Circuits', - icon_class='mdi mdi-transit-connection-variant', - groups=( - MenuGroup( - label='Circuits', - items=( - get_model_item('circuits', 'circuit', 'Circuits'), - get_model_item('circuits', 'circuittype', 'Circuit Types'), - ), - ), - MenuGroup( - label='Providers', - items=( - get_model_item('circuits', 'provider', 'Providers'), - get_model_item('circuits', 'providernetwork', 'Provider Networks'), - ), - ), - ), -) - -POWER_MENU = Menu( - label='Power', - icon_class='mdi mdi-flash', - groups=( - MenuGroup( - label='Power', - items=( - get_model_item('dcim', 'powerfeed', 'Power Feeds'), - get_model_item('dcim', 'powerpanel', 'Power Panels'), - ), - ), - ), -) - -OTHER_MENU = Menu( - label='Other', - icon_class='mdi mdi-notification-clear-all', - groups=( - MenuGroup( - label='Logging', - items=( - get_model_item('extras', 'journalentry', 'Journal Entries', actions=[]), - get_model_item('extras', 'objectchange', 'Change Log', actions=[]), - ), - ), - MenuGroup( - label='Customization', - items=( - get_model_item('extras', 'customfield', 'Custom Fields'), - get_model_item('extras', 'customlink', 'Custom Links'), - get_model_item('extras', 'exporttemplate', 'Export Templates'), - ), - ), - MenuGroup( - label='Integrations', - items=( - get_model_item('extras', 'webhook', 'Webhooks'), - MenuItem( - link='extras:report_list', - link_text='Reports', - permissions=['extras.view_report'] - ), - MenuItem( - link='extras:script_list', - link_text='Scripts', - permissions=['extras.view_script'] - ), - ), - ), - MenuGroup( - label='Other', - items=( - get_model_item('extras', 'tag', 'Tags'), - get_model_item('extras', 'configcontext', 'Config Contexts', actions=['add']), - ), - ), - ), -) - - -MENUS = [ - ORGANIZATION_MENU, - DEVICES_MENU, - CONNECTIONS_MENU, - WIRELESS_MENU, - IPAM_MENU, - OVERLAY_MENU, - VIRTUALIZATION_MENU, - CIRCUITS_MENU, - POWER_MENU, - OTHER_MENU, -] - -# -# Add plugin menus -# - -if registry['plugins']['menu_items']: - plugin_menu_groups = [] - - for plugin_name, items in registry['plugins']['menu_items'].items(): - plugin_menu_groups.append( - MenuGroup( - label=plugin_name, - items=items - ) - ) - - PLUGIN_MENU = Menu( - label="Plugins", - icon_class="mdi mdi-puzzle", - groups=plugin_menu_groups - ) - - MENUS.append(PLUGIN_MENU) diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 6bf56b562..c88b56072 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,4 +1,5 @@ -from extras.registry import registry +from django.utils.translation import gettext as _ +from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator @@ -13,7 +14,7 @@ PREFERENCES = { # User interface 'ui.colormode': UserPreference( - label='Color mode', + label=_('Color mode'), choices=( ('light', 'Light'), ('dark', 'Dark'), @@ -21,25 +22,25 @@ PREFERENCES = { default='light', ), 'pagination.per_page': UserPreference( - label='Page length', + label=_('Page length'), choices=get_page_lengths(), - description='The number of objects to display per page', + description=_('The number of objects to display per page'), coerce=lambda x: int(x) ), 'pagination.placement': UserPreference( - label='Paginator placement', + label=_('Paginator placement'), choices=( ('bottom', 'Bottom'), ('top', 'Top'), ('both', 'Both'), ), - description='Where the paginator controls will be displayed relative to a table', + description=_('Where the paginator controls will be displayed relative to a table'), default='bottom' ), # Miscellaneous 'data_format': UserPreference( - label='Data format', + label=_('Data format'), choices=( ('json', 'JSON'), ('yaml', 'YAML'), diff --git a/netbox/extras/registry.py b/netbox/netbox/registry.py similarity index 92% rename from netbox/extras/registry.py rename to netbox/netbox/registry.py index e1437c00e..76886e791 100644 --- a/netbox/extras/registry.py +++ b/netbox/netbox/registry.py @@ -29,3 +29,5 @@ registry['model_features'] = { feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } registry['denormalized_fields'] = collections.defaultdict(list) +registry['search'] = dict() +registry['views'] = collections.defaultdict(dict) diff --git a/netbox/netbox/search.py b/netbox/netbox/search.py deleted file mode 100644 index 6d1c5d4a3..000000000 --- a/netbox/netbox/search.py +++ /dev/null @@ -1,274 +0,0 @@ -import circuits.filtersets -import circuits.tables -import dcim.filtersets -import dcim.tables -import extras.filtersets -import extras.tables -import ipam.filtersets -import ipam.tables -import tenancy.filtersets -import tenancy.tables -import virtualization.filtersets -import wireless.tables -import wireless.filtersets -import virtualization.tables -from circuits.models import Circuit, ProviderNetwork, Provider -from dcim.models import ( - Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, - VirtualChassis, -) -from extras.models import JournalEntry -from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF -from tenancy.models import Contact, Tenant, ContactAssignment -from utilities.utils import count_related -from wireless.models import WirelessLAN, WirelessLink -from virtualization.models import Cluster, VirtualMachine - -CIRCUIT_TYPES = { - 'provider': { - 'queryset': Provider.objects.annotate( - count_circuits=count_related(Circuit, 'provider') - ), - 'filterset': circuits.filtersets.ProviderFilterSet, - 'table': circuits.tables.ProviderTable, - 'url': 'circuits:provider_list', - }, - 'circuit': { - 'queryset': Circuit.objects.prefetch_related( - 'type', 'provider', 'tenant', 'tenant__group', 'terminations__site' - ), - 'filterset': circuits.filtersets.CircuitFilterSet, - 'table': circuits.tables.CircuitTable, - 'url': 'circuits:circuit_list', - }, - 'providernetwork': { - 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': circuits.filtersets.ProviderNetworkFilterSet, - 'table': circuits.tables.ProviderNetworkTable, - 'url': 'circuits:providernetwork_list', - }, -} - -DCIM_TYPES = { - 'site': { - 'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'), - 'filterset': dcim.filtersets.SiteFilterSet, - 'table': dcim.tables.SiteTable, - 'url': 'dcim:site_list', - }, - 'rack': { - 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate( - device_count=count_related(Device, 'rack') - ), - 'filterset': dcim.filtersets.RackFilterSet, - 'table': dcim.tables.RackTable, - 'url': 'dcim:rack_list', - }, - 'rackreservation': { - 'queryset': RackReservation.objects.prefetch_related('rack', 'user'), - 'filterset': dcim.filtersets.RackReservationFilterSet, - 'table': dcim.tables.RackReservationTable, - 'url': 'dcim:rackreservation_list', - }, - 'location': { - 'queryset': Location.objects.add_related_count( - Location.objects.add_related_count( - Location.objects.all(), - Device, - 'location', - 'device_count', - cumulative=True - ), - Rack, - 'location', - 'rack_count', - cumulative=True - ).prefetch_related('site'), - 'filterset': dcim.filtersets.LocationFilterSet, - 'table': dcim.tables.LocationTable, - 'url': 'dcim:location_list', - }, - 'devicetype': { - 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=count_related(Device, 'device_type') - ), - 'filterset': dcim.filtersets.DeviceTypeFilterSet, - 'table': dcim.tables.DeviceTypeTable, - 'url': 'dcim:devicetype_list', - }, - 'device': { - 'queryset': Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4', - 'primary_ip6', - ), - 'filterset': dcim.filtersets.DeviceFilterSet, - 'table': dcim.tables.DeviceTable, - 'url': 'dcim:device_list', - }, - 'moduletype': { - 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( - instance_count=count_related(Module, 'module_type') - ), - 'filterset': dcim.filtersets.ModuleTypeFilterSet, - 'table': dcim.tables.ModuleTypeTable, - 'url': 'dcim:moduletype_list', - }, - 'module': { - 'queryset': Module.objects.prefetch_related( - 'module_type__manufacturer', 'device', 'module_bay', - ), - 'filterset': dcim.filtersets.ModuleFilterSet, - 'table': dcim.tables.ModuleTable, - 'url': 'dcim:module_list', - }, - 'virtualchassis': { - 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( - member_count=count_related(Device, 'virtual_chassis') - ), - 'filterset': dcim.filtersets.VirtualChassisFilterSet, - 'table': dcim.tables.VirtualChassisTable, - 'url': 'dcim:virtualchassis_list', - }, - 'cable': { - 'queryset': Cable.objects.all(), - 'filterset': dcim.filtersets.CableFilterSet, - 'table': dcim.tables.CableTable, - 'url': 'dcim:cable_list', - }, - 'powerfeed': { - 'queryset': PowerFeed.objects.all(), - 'filterset': dcim.filtersets.PowerFeedFilterSet, - 'table': dcim.tables.PowerFeedTable, - 'url': 'dcim:powerfeed_list', - }, -} - -IPAM_TYPES = { - 'vrf': { - 'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'), - 'filterset': ipam.filtersets.VRFFilterSet, - 'table': ipam.tables.VRFTable, - 'url': 'ipam:vrf_list', - }, - 'aggregate': { - 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': ipam.filtersets.AggregateFilterSet, - 'table': ipam.tables.AggregateTable, - 'url': 'ipam:aggregate_list', - }, - 'prefix': { - 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'), - 'filterset': ipam.filtersets.PrefixFilterSet, - 'table': ipam.tables.PrefixTable, - 'url': 'ipam:prefix_list', - }, - 'ipaddress': { - 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'), - 'filterset': ipam.filtersets.IPAddressFilterSet, - 'table': ipam.tables.IPAddressTable, - 'url': 'ipam:ipaddress_list', - }, - 'vlan': { - 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'), - 'filterset': ipam.filtersets.VLANFilterSet, - 'table': ipam.tables.VLANTable, - 'url': 'ipam:vlan_list', - }, - 'asn': { - 'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'), - 'filterset': ipam.filtersets.ASNFilterSet, - 'table': ipam.tables.ASNTable, - 'url': 'ipam:asn_list', - }, - 'service': { - 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), - 'filterset': ipam.filtersets.ServiceFilterSet, - 'table': ipam.tables.ServiceTable, - 'url': 'ipam:service_list', - }, -} - -TENANCY_TYPES = { - 'tenant': { - 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': tenancy.filtersets.TenantFilterSet, - 'table': tenancy.tables.TenantTable, - 'url': 'tenancy:tenant_list', - }, - 'contact': { - 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( - assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': tenancy.filtersets.ContactFilterSet, - 'table': tenancy.tables.ContactTable, - 'url': 'tenancy:contact_list', - }, -} - -VIRTUALIZATION_TYPES = { - 'cluster': { - 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( - device_count=count_related(Device, 'cluster'), - vm_count=count_related(VirtualMachine, 'cluster') - ), - 'filterset': virtualization.filtersets.ClusterFilterSet, - 'table': virtualization.tables.ClusterTable, - 'url': 'virtualization:cluster_list', - }, - 'virtualmachine': { - 'queryset': VirtualMachine.objects.prefetch_related( - 'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6', - ), - 'filterset': virtualization.filtersets.VirtualMachineFilterSet, - 'table': virtualization.tables.VirtualMachineTable, - 'url': 'virtualization:virtualmachine_list', - }, -} - -WIRELESS_TYPES = { - 'wirelesslan': { - 'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate( - interface_count=count_related(Interface, 'wireless_lans') - ), - 'filterset': wireless.filtersets.WirelessLANFilterSet, - 'table': wireless.tables.WirelessLANTable, - 'url': 'wireless:wirelesslan_list', - }, - 'wirelesslink': { - 'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'), - 'filterset': wireless.filtersets.WirelessLinkFilterSet, - 'table': wireless.tables.WirelessLinkTable, - 'url': 'wireless:wirelesslink_list', - }, -} - -JOURNAL_TYPES = { - 'journalentry': { - 'queryset': JournalEntry.objects.prefetch_related('assigned_object', 'created_by'), - 'filterset': extras.filtersets.JournalEntryFilterSet, - 'table': extras.tables.JournalEntryTable, - 'url': 'extras:journalentry_list', - }, -} - -SEARCH_TYPE_HIERARCHY = { - 'Circuits': CIRCUIT_TYPES, - 'DCIM': DCIM_TYPES, - 'IPAM': IPAM_TYPES, - 'Tenancy': TENANCY_TYPES, - 'Virtualization': VIRTUALIZATION_TYPES, - 'Wireless': WIRELESS_TYPES, - 'Journal': JOURNAL_TYPES, -} - - -def build_search_types(): - result = dict() - - for app_types in SEARCH_TYPE_HIERARCHY.values(): - for name, items in app_types.items(): - result[name] = items - - return result - - -SEARCH_TYPES = build_search_types() diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py new file mode 100644 index 000000000..1eec8e097 --- /dev/null +++ b/netbox/netbox/search/__init__.py @@ -0,0 +1,116 @@ +from collections import namedtuple + +from django.db import models + +from netbox.registry import registry + +ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value')) + + +class FieldTypes: + FLOAT = 'float' + INTEGER = 'int' + STRING = 'str' + + +class LookupTypes: + PARTIAL = 'icontains' + EXACT = 'iexact' + STARTSWITH = 'istartswith' + ENDSWITH = 'iendswith' + REGEX = 'iregex' + + +class SearchIndex: + """ + Base class for building search indexes. + + Attributes: + model: The model class for which this index is used. + category: The label of the group under which this indexer is categorized (for form field display). If none, + the name of the model's app will be used. + fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each. + """ + model = None + category = None + fields = () + + @staticmethod + def get_field_type(instance, field_name): + """ + Return the data type of the specified model field. + """ + field_cls = instance._meta.get_field(field_name).__class__ + if issubclass(field_cls, (models.FloatField, models.DecimalField)): + return FieldTypes.FLOAT + if issubclass(field_cls, models.IntegerField): + return FieldTypes.INTEGER + return FieldTypes.STRING + + @staticmethod + def get_field_value(instance, field_name): + """ + Return the value of the specified model field as a string. + """ + return str(getattr(instance, field_name)) + + @classmethod + def get_category(cls): + return cls.category or cls.model._meta.app_config.verbose_name + + @classmethod + def to_cache(cls, instance, custom_fields=None): + """ + Return a list of ObjectFieldValue representing the instance fields to be cached. + + Args: + instance: The instance being cached. + custom_fields: An iterable of CustomFields to include when caching the instance. If None, all custom fields + defined for the model will be included. (This can also be provided during bulk caching to avoid looking + up the available custom fields for each instance.) + """ + values = [] + + # Capture built-in fields + for name, weight in cls.fields: + type_ = cls.get_field_type(instance, name) + value = cls.get_field_value(instance, name) + if type_ and value: + values.append( + ObjectFieldValue(name, type_, weight, value) + ) + + # Capture custom fields + if getattr(instance, 'custom_field_data', None): + if custom_fields is None: + custom_fields = instance.custom_fields + for cf in custom_fields: + type_ = cf.search_type + value = instance.custom_field_data.get(cf.name) + weight = cf.search_weight + if type_ and value and weight: + values.append( + ObjectFieldValue(f'cf_{cf.name}', type_, weight, value) + ) + + return values + + +def get_indexer(model): + """ + Get the SearchIndex class for the given model. + """ + label = f'{model._meta.app_label}.{model._meta.model_name}' + + return registry['search'][label] + + +def register_search(cls): + """ + Decorator for registering a SearchIndex class. + """ + model = cls.model + label = f'{model._meta.app_label}.{model._meta.model_name}' + registry['search'][label] = cls + + return cls diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py new file mode 100644 index 000000000..dfc251aa9 --- /dev/null +++ b/netbox/netbox/search/backends.py @@ -0,0 +1,235 @@ +from collections import defaultdict + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ImproperlyConfigured +from django.db.models import F, Window +from django.db.models.functions import window +from django.db.models.signals import post_delete, post_save +from django.utils.module_loading import import_string + +from extras.models import CachedValue, CustomField +from netbox.registry import registry +from utilities.querysets import RestrictedPrefetch +from utilities.utils import title +from . import FieldTypes, LookupTypes, get_indexer + +DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL +MAX_RESULTS = 1000 + + +class SearchBackend: + """ + Base class for search backends. Subclasses must extend the `cache()`, `remove()`, and `clear()` methods below. + """ + _object_types = None + + def get_object_types(self): + """ + Return a list of all registered object types, organized by category, suitable for populating a form's + ChoiceField. + """ + if not self._object_types: + + # Organize choices by category + categories = defaultdict(dict) + for label, idx in registry['search'].items(): + categories[idx.get_category()][label] = title(idx.model._meta.verbose_name) + + # Compile a nested tuple of choices for form rendering + results = ( + ('', 'All Objects'), + *[(category, list(choices.items())) for category, choices in categories.items()] + ) + + self._object_types = results + + return self._object_types + + def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE): + """ + Search cached object representations for the given value. + """ + raise NotImplementedError + + def caching_handler(self, sender, instance, **kwargs): + """ + Receiver for the post_save signal, responsible for caching object creation/changes. + """ + self.cache(instance) + + def removal_handler(self, sender, instance, **kwargs): + """ + Receiver for the post_delete signal, responsible for caching object deletion. + """ + self.remove(instance) + + def cache(self, instances, indexer=None, remove_existing=True): + """ + Create or update the cached representation of an instance. + """ + raise NotImplementedError + + def remove(self, instance): + """ + Delete any cached representation of an instance. + """ + raise NotImplementedError + + def clear(self, object_types=None): + """ + Delete *all* cached data. + """ + raise NotImplementedError + + @property + def size(self): + """ + Return a total number of cached entries. The meaning of this value will be + backend-dependent. + """ + return None + + +class CachedValueSearchBackend(SearchBackend): + + def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE): + + # Define the search parameters + params = { + f'value__{lookup}': value + } + if lookup != LookupTypes.EXACT: + # Partial matches are valid only on string values + params['type'] = FieldTypes.STRING + if object_types: + params['object_type__in'] = object_types + + # Construct the base queryset to retrieve matching results + queryset = CachedValue.objects.filter(**params).annotate( + # Annotate the rank of each result for its object according to its weight + row_number=Window( + expression=window.RowNumber(), + partition_by=[F('object_type'), F('object_id')], + order_by=[F('weight').asc()], + ) + )[:MAX_RESULTS] + + # Construct a Prefetch to pre-fetch only those related objects for which the + # user has permission to view. + if user: + prefetch = (RestrictedPrefetch('object', user, 'view'), 'object_type') + else: + prefetch = ('object', 'object_type') + + # Wrap the base query to return only the lowest-weight result for each object + # Hat-tip to https://blog.oyam.dev/django-filter-by-window-function/ for the solution + sql, params = queryset.query.sql_with_params() + results = CachedValue.objects.prefetch_related(*prefetch).raw( + f"SELECT * FROM ({sql}) t WHERE row_number = 1", + params + ) + + # Omit any results pertaining to an object the user does not have permission to view + return [ + r for r in results if r.object is not None + ] + + def cache(self, instances, indexer=None, remove_existing=True): + content_type = None + custom_fields = None + + # Convert a single instance to an iterable + if not hasattr(instances, '__iter__'): + instances = [instances] + + buffer = [] + counter = 0 + for instance in instances: + + # First item + if not counter: + + # Determine the indexer + if indexer is None: + try: + indexer = get_indexer(instance) + except KeyError: + break + + # Prefetch any associated custom fields + content_type = ContentType.objects.get_for_model(indexer.model) + custom_fields = CustomField.objects.filter(content_types=content_type).exclude(search_weight=0) + + # Wipe out any previously cached values for the object + if remove_existing: + self.remove(instance) + + # Generate cache data + for field in indexer.to_cache(instance, custom_fields=custom_fields): + buffer.append( + CachedValue( + object_type=content_type, + object_id=instance.pk, + field=field.name, + type=field.type, + weight=field.weight, + value=field.value + ) + ) + + # Check whether the buffer needs to be flushed + if len(buffer) >= 2000: + counter += len(CachedValue.objects.bulk_create(buffer)) + buffer = [] + + # Final buffer flush + if buffer: + counter += len(CachedValue.objects.bulk_create(buffer)) + + return counter + + def remove(self, instance): + # Avoid attempting to query for non-cacheable objects + try: + get_indexer(instance) + except KeyError: + return + + ct = ContentType.objects.get_for_model(instance) + qs = CachedValue.objects.filter(object_type=ct, object_id=instance.pk) + + # Call _raw_delete() on the queryset to avoid first loading instances into memory + return qs._raw_delete(using=qs.db) + + def clear(self, object_types=None): + qs = CachedValue.objects.all() + if object_types: + qs = qs.filter(object_type__in=object_types) + + # Call _raw_delete() on the queryset to avoid first loading instances into memory + return qs._raw_delete(using=qs.db) + + @property + def size(self): + return CachedValue.objects.count() + + +def get_backend(): + """ + Initializes and returns the configured search backend. + """ + try: + backend_cls = import_string(settings.SEARCH_BACKEND) + except AttributeError: + raise ImproperlyConfigured(f"Failed to import configured SEARCH_BACKEND: {settings.SEARCH_BACKEND}") + + # Initialize and return the backend instance + return backend_cls() + + +search_backend = get_backend() + +# Connect handlers to the appropriate model signals +post_save.connect(search_backend.caching_handler) +post_delete.connect(search_backend.removal_handler) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b9ad5c1d0..eb516a8f9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,35 +1,30 @@ import hashlib import importlib -import logging +import importlib.util import os import platform -import re -import socket import sys import warnings from urllib.parse import urlsplit +import django import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from django.utils.encoding import force_str +from extras.plugins import PluginConfig from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS - -# Monkey patch to fix Django 4.0 support for graphene-django (see -# https://github.com/graphql-python/graphene-django/issues/1284) -# TODO: Remove this when graphene-django 2.16 becomes available -import django -from django.utils.encoding import force_str -django.utils.encoding.force_text = force_str +from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # # Environment setup # -VERSION = '3.3.10' +VERSION = '3.4.0' # Hostname HOSTNAME = platform.node() @@ -77,6 +72,7 @@ DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: @@ -107,6 +103,7 @@ MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media' METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) +QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') @@ -124,6 +121,7 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') +SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) @@ -188,7 +186,7 @@ if STORAGE_BACKEND is not None: if STORAGE_BACKEND.startswith('storages.'): try: - import storages.utils + import storages.utils # type: ignore except ModuleNotFoundError as e: if getattr(e, 'name') == 'storages': raise ImproperlyConfigured( @@ -389,10 +387,9 @@ AUTHENTICATION_BACKENDS = [ # Internationalization LANGUAGE_CODE = 'en-us' -USE_I18N = True -USE_L10N = False + +# Time zones USE_TZ = True -USE_DEPRECATED_PYTZ = True # WSGI WSGI_APPLICATION = 'netbox.wsgi.application' @@ -645,18 +642,22 @@ RQ_PARAMS.update({ }) RQ_QUEUES = { - 'high': RQ_PARAMS, - 'default': RQ_PARAMS, - 'low': RQ_PARAMS, + RQ_QUEUE_HIGH: RQ_PARAMS, + RQ_QUEUE_DEFAULT: RQ_PARAMS, + RQ_QUEUE_LOW: RQ_PARAMS, } +# Add any queues defined in QUEUE_MAPPINGS +RQ_QUEUES.update({ + queue: RQ_PARAMS for queue in set(QUEUE_MAPPINGS.values()) if queue not in RQ_QUEUES +}) + # # Plugins # for plugin_name in PLUGINS: - # Import plugin module try: plugin = importlib.import_module(plugin_name) @@ -670,14 +671,42 @@ for plugin_name in PLUGINS: # Determine plugin config and add to INSTALLED_APPS. try: - plugin_config = plugin.config - INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__)) + plugin_config: PluginConfig = plugin.config except AttributeError: raise ImproperlyConfigured( "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " "and point to the PluginConfig subclass.".format(plugin_name) ) + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + + # Gather additional apps to load alongside this plugin + django_apps = plugin_config.django_apps + if plugin_name in django_apps: + django_apps.pop(plugin_name) + if plugin_module not in django_apps: + django_apps.append(plugin_module) + + # Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs) + for app in django_apps: + if "." in app: + parts = app.split(".") + spec = importlib.util.find_spec(".".join(parts[:-1])) + else: + spec = importlib.util.find_spec(app) + if spec is None: + raise ImproperlyConfigured( + f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} " + f"The module {app} cannot be imported. Check that the necessary package has been " + "installed within the correct Python environment." + ) + + INSTALLED_APPS.extend(django_apps) + + # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence + sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) + INSTALLED_APPS = list(sorted_apps) + # Validate user-provided configuration settings and assign defaults if plugin_name not in PLUGINS_CONFIG: PLUGINS_CONFIG[plugin_name] = {} diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py new file mode 100644 index 000000000..ec38dcadc --- /dev/null +++ b/netbox/netbox/staging.py @@ -0,0 +1,148 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models.signals import m2m_changed, pre_delete, post_save + +from extras.choices import ChangeActionChoices +from extras.models import StagedChange +from utilities.utils import serialize_object + +logger = logging.getLogger('netbox.staging') + + +class checkout: + """ + Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band + (as Change instances) for application at a later time, without modifying the production + database. + + branch = Branch.objects.create(name='my-branch') + with checkout(branch): + # All changes made herein will be rolled back and stored for later + + Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks, + and restores its original value upon exit. + """ + def __init__(self, branch): + self.branch = branch + self.queue = {} + + def __enter__(self): + + # Disable autocommit to effect a new transaction + logger.debug(f"Entering transaction for {self.branch}") + self._autocommit = transaction.get_autocommit() + transaction.set_autocommit(False) + + # Apply any existing Changes assigned to this Branch + staged_changes = self.branch.staged_changes.all() + if change_count := staged_changes.count(): + logger.debug(f"Applying {change_count} pre-staged changes...") + for change in staged_changes: + change.apply() + else: + logger.debug("No pre-staged changes found") + + # Connect signal handlers + logger.debug("Connecting signal handlers") + post_save.connect(self.post_save_handler) + m2m_changed.connect(self.post_save_handler) + pre_delete.connect(self.pre_delete_handler) + + def __exit__(self, exc_type, exc_val, exc_tb): + + # Disconnect signal handlers + logger.debug("Disconnecting signal handlers") + post_save.disconnect(self.post_save_handler) + m2m_changed.disconnect(self.post_save_handler) + pre_delete.disconnect(self.pre_delete_handler) + + # Roll back the transaction to return the database to its original state + logger.debug("Rolling back database transaction") + transaction.rollback() + logger.debug(f"Restoring autocommit state ({self._autocommit})") + transaction.set_autocommit(self._autocommit) + + # Process queued changes + self.process_queue() + + # + # Queuing + # + + @staticmethod + def get_key_for_instance(instance): + return ContentType.objects.get_for_model(instance), instance.pk + + def process_queue(self): + """ + Create Change instances for all actions stored in the queue. + """ + if not self.queue: + logger.debug(f"No queued changes; aborting") + return + logger.debug(f"Processing {len(self.queue)} queued changes") + + # Iterate through the in-memory queue, creating Change instances + changes = [] + for key, change in self.queue.items(): + logger.debug(f' {key}: {change}') + object_type, pk = key + action, data = change + + changes.append(StagedChange( + branch=self.branch, + action=action, + object_type=object_type, + object_id=pk, + data=data + )) + + # Save all Change instances to the database + StagedChange.objects.bulk_create(changes) + + # + # Signal handlers + # + + def post_save_handler(self, sender, instance, **kwargs): + """ + Hooks to the post_save signal when a branch is active to queue create and update actions. + """ + key = self.get_key_for_instance(instance) + object_type = instance._meta.verbose_name + + # Creating a new object + if kwargs.get('created'): + logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data) + return + + # Ignore pre_* many-to-many actions + if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'): + return + + # Object has already been created/updated in the queue; update its queued representation + if key in self.queue: + logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (self.queue[key][0], data) + return + + # Modifying an existing object for the first time + logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data) + + def pre_delete_handler(self, sender, instance, **kwargs): + """ + Hooks to the pre_delete signal when a branch is active to queue delete actions. + """ + key = self.get_key_for_instance(instance) + object_type = instance._meta.verbose_name + + # Delete an existing object + logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})") + self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 0987b8821..2f5c228e4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -29,6 +29,7 @@ __all__ = ( 'ContentTypesColumn', 'CustomFieldColumn', 'CustomLinkColumn', + 'DurationColumn', 'LinkedCountColumn', 'MarkdownColumn', 'ManyToManyColumn', @@ -82,6 +83,24 @@ class DateTimeColumn(tables.DateTimeColumn): return cls(**kwargs) +class DurationColumn(tables.Column): + """ + Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m" + """ + def render(self, value): + ret = '' + if days := value // 1440: + ret += f'{days}d ' + if hours := value % 1440 // 60: + ret += f'{hours}h ' + if minutes := value % 60: + ret += f'{minutes}m' + return ret.strip() + + def value(self, value): + return value + + class ManyToManyColumn(tables.ManyToManyColumn): """ Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. @@ -305,7 +324,7 @@ class ContentTypeColumn(tables.Column): def render(self, value): if value is None: return None - return content_type_name(value) + return content_type_name(value, include_app=False) def value(self, value): if value is None: @@ -324,7 +343,7 @@ class ContentTypesColumn(tables.ManyToManyColumn): super().__init__(separator=separator, *args, **kwargs) def transform(self, obj): - return content_type_name(obj) + return content_type_name(obj, include_app=False) def value(self, value): return ','.join([ diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 38399b5fe..3a2e71084 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -4,16 +4,20 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField +from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.utils import highlight_string, title __all__ = ( 'BaseTable', 'NetBoxTable', + 'SearchTable', ) @@ -186,9 +190,45 @@ class NetBoxTable(BaseTable): extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) extra_columns.extend([ (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links ]) super().__init__(*args, extra_columns=extra_columns, **kwargs) + + +class SearchTable(tables.Table): + object_type = columns.ContentTypeColumn( + verbose_name=_('Type') + ) + object = tables.Column( + linkify=True + ) + field = tables.Column() + value = tables.Column() + + trim_length = 30 + + class Meta: + attrs = { + 'class': 'table table-hover object-list', + } + empty_text = _('No results found') + + def __init__(self, data, highlight=None, **kwargs): + self.highlight = highlight + super().__init__(data, **kwargs) + + def render_field(self, value, record): + if hasattr(record.object, value): + return title(record.object._meta.get_field(value).verbose_name) + return value + + def render_value(self, value): + if not self.highlight: + return value + + value = highlight_string(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) + + return mark_safe(value) diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py new file mode 100644 index 000000000..88d2605c5 --- /dev/null +++ b/netbox/netbox/tests/test_import.py @@ -0,0 +1,87 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings + +from dcim.models import * +from users.models import ObjectPermission +from utilities.choices import ImportFormatChoices +from utilities.testing import ModelViewTestCase, create_tags + + +class CSVImportTestCase(ModelViewTestCase): + model = Region + + @classmethod + def setUpTestData(cls): + create_tags('Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo') + + def _get_csv_data(self, csv_data): + return '\n'.join(csv_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_valid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"alpha,bravo"', + 'Region 2,region-2,"charlie,delta"', + 'Region 3,region-3,echo', + 'Region 4,region-4,', + ) + + data = { + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + regions = Region.objects.all() + self.assertEqual(regions.count(), 4) + region = Region.objects.get(slug="region-4") + self.assertEqual( + list(regions[0].tags.values_list('name', flat=True)), + ['Alpha', 'Bravo'] + ) + self.assertEqual( + list(regions[1].tags.values_list('name', flat=True)), + ['Charlie', 'Delta'] + ) + self.assertEqual( + list(regions[2].tags.values_list('name', flat=True)), + ['Echo'] + ) + self.assertEqual(regions[3].tags.count(), 0) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_invalid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"Alpha,Bravo"', # Valid + 'Region 2,region-2,"Alpha,Tango"', # Invalid + ) + + data = { + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(Region.objects.count(), 0) diff --git a/netbox/extras/tests/test_registry.py b/netbox/netbox/tests/test_registry.py similarity index 94% rename from netbox/extras/tests/test_registry.py rename to netbox/netbox/tests/test_registry.py index 38a6b9f83..25f9e43ec 100644 --- a/netbox/extras/tests/test_registry.py +++ b/netbox/netbox/tests/test_registry.py @@ -1,6 +1,6 @@ from django.test import TestCase -from extras.registry import Registry +from netbox.registry import Registry class RegistryTest(TestCase): diff --git a/netbox/netbox/tests/test_search.py b/netbox/netbox/tests/test_search.py new file mode 100644 index 000000000..1b6fe9eac --- /dev/null +++ b/netbox/netbox/tests/test_search.py @@ -0,0 +1,153 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Site +from dcim.search import SiteIndex +from extras.models import CachedValue +from netbox.search.backends import search_backend + + +class SearchBackendTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + # Create sites with a value for each cacheable field defined on SiteIndex + sites = ( + Site( + name='Site 1', + slug='site-1', + facility='Alpha', + description='First test site', + physical_address='123 Fake St Lincoln NE 68588', + shipping_address='123 Fake St Lincoln NE 68588', + comments='Lorem ipsum etcetera' + ), + Site( + name='Site 2', + slug='site-2', + facility='Bravo', + description='Second test site', + physical_address='725 Cyrus Valleys Suite 761 Douglasfort NE 57761', + shipping_address='725 Cyrus Valleys Suite 761 Douglasfort NE 57761', + comments='Lorem ipsum etcetera' + ), + Site( + name='Site 3', + slug='site-3', + facility='Charlie', + description='Third test site', + physical_address='2321 Dovie Dale East Cristobal AK 71959', + shipping_address='2321 Dovie Dale East Cristobal AK 71959', + comments='Lorem ipsum etcetera' + ), + ) + Site.objects.bulk_create(sites) + + def test_cache_single_object(self): + """ + Test that a single object is cached appropriately + """ + site = Site.objects.first() + search_backend.cache(site) + + content_type = ContentType.objects.get_for_model(Site) + self.assertEqual( + CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(), + len(SiteIndex.fields) + ) + for field_name, weight in SiteIndex.fields: + self.assertTrue( + CachedValue.objects.filter( + object_type=content_type, + object_id=site.pk, + field=field_name, + value=getattr(site, field_name), + weight=weight + ), + ) + + def test_cache_multiple_objects(self): + """ + Test that multiples objects are cached appropriately + """ + sites = Site.objects.all() + search_backend.cache(sites) + + content_type = ContentType.objects.get_for_model(Site) + self.assertEqual( + CachedValue.objects.filter(object_type=content_type).count(), + len(SiteIndex.fields) * sites.count() + ) + for site in sites: + for field_name, weight in SiteIndex.fields: + self.assertTrue( + CachedValue.objects.filter( + object_type=content_type, + object_id=site.pk, + field=field_name, + value=getattr(site, field_name), + weight=weight + ), + ) + + def test_cache_on_save(self): + """ + Test that an object is automatically cached on calling save(). + """ + site = Site( + name='Site 4', + slug='site-4', + facility='Delta', + description='Fourth test site', + physical_address='7915 Lilla Plains West Ladariusport TX 19429', + shipping_address='7915 Lilla Plains West Ladariusport TX 19429', + comments='Lorem ipsum etcetera' + ) + site.save() + + content_type = ContentType.objects.get_for_model(Site) + self.assertEqual( + CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(), + len(SiteIndex.fields) + ) + + def test_remove_on_delete(self): + """ + Test that any cached value for an object are automatically removed on delete(). + """ + site = Site.objects.first() + site.delete() + + content_type = ContentType.objects.get_for_model(Site) + self.assertFalse( + CachedValue.objects.filter(object_type=content_type, object_id=site.pk).exists() + ) + + def test_clear_all(self): + """ + Test that calling clear() on the backend removes all cached entries. + """ + sites = Site.objects.all() + search_backend.cache(sites) + self.assertTrue( + CachedValue.objects.exists() + ) + + search_backend.clear() + self.assertFalse( + CachedValue.objects.exists() + ) + + def test_search(self): + """ + Test various searches. + """ + sites = Site.objects.all() + search_backend.cache(sites) + + results = search_backend.search('site') + self.assertEqual(len(results), 3) + results = search_backend.search('first') + self.assertEqual(len(results), 1) + results = search_backend.search('xxxxx') + self.assertEqual(len(results), 0) diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py new file mode 100644 index 000000000..ed3a69f10 --- /dev/null +++ b/netbox/netbox/tests/test_staging.py @@ -0,0 +1,210 @@ +from django.test import TransactionTestCase + +from circuits.models import Provider, Circuit, CircuitType +from extras.choices import ChangeActionChoices +from extras.models import Branch, StagedChange, Tag +from ipam.models import ASN, RIR +from netbox.staging import checkout +from utilities.testing import create_tags + + +class StagingTestCase(TransactionTestCase): + + def setUp(self): + create_tags('Alpha', 'Bravo', 'Charlie') + + rir = RIR.objects.create(name='RIR 1', slug='rir-1') + asns = ( + ASN(asn=65001, rir=rir), + ASN(asn=65002, rir=rir), + ASN(asn=65003, rir=rir), + ) + ASN.objects.bulk_create(asns) + + providers = ( + Provider(name='Provider A', slug='provider-a'), + Provider(name='Provider B', slug='provider-b'), + Provider(name='Provider C', slug='provider-c'), + ) + Provider.objects.bulk_create(providers) + + circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + + Circuit.objects.bulk_create(( + Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type), + Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type), + Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type), + )) + + def test_object_creation(self): + branch = Branch.objects.create(name='Branch 1') + tags = Tag.objects.all() + asns = ASN.objects.all() + + with checkout(branch): + provider = Provider.objects.create(name='Provider D', slug='provider-d') + provider.asns.set(asns) + circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first()) + circuit.tags.set(tags) + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 4) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 10) + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(StagedChange.objects.count(), 5) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 4) + self.assertEqual(Circuit.objects.count(), 10) + provider = Provider.objects.get(name='Provider D') + self.assertListEqual(list(provider.asns.all()), list(asns)) + circuit = Circuit.objects.get(cid='Circuit D1') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 4) + self.assertEqual(Circuit.objects.count(), 10) + provider = Provider.objects.get(name='Provider D') + self.assertListEqual(list(provider.asns.all()), list(asns)) + circuit = Circuit.objects.get(cid='Circuit D1') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_object_modification(self): + branch = Branch.objects.create(name='Branch 1') + tags = Tag.objects.all() + asns = ASN.objects.all() + + with checkout(branch): + provider = Provider.objects.get(name='Provider A') + provider.name = 'Provider X' + provider.save() + provider.asns.set(asns) + circuit = Circuit.objects.get(cid='Circuit A1') + circuit.cid = 'Circuit X' + circuit.save() + circuit.tags.set(tags) + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), []) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit A1') + self.assertListEqual(list(circuit.tags.all()), []) + self.assertEqual(StagedChange.objects.count(), 5) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_object_deletion(self): + branch = Branch.objects.create(name='Branch 1') + + with checkout(branch): + provider = Provider.objects.get(name='Provider A') + provider.circuits.all().delete() + provider.delete() + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(StagedChange.objects.count(), 4) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_exit_enter_context(self): + branch = Branch.objects.create(name='Branch 1') + + with checkout(branch): + + # Create a new object + provider = Provider.objects.create(name='Provider D', slug='provider-d') + provider.save() + + # Check that a create Change was recorded + self.assertEqual(StagedChange.objects.count(), 1) + change = StagedChange.objects.first() + self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE) + self.assertEqual(change.data['name'], provider.name) + + with checkout(branch): + + # Update the staged object + provider = Provider.objects.get(name='Provider D') + provider.comments = 'New comments' + provider.save() + + # Check that a second Change object has been created for the object + self.assertEqual(StagedChange.objects.count(), 2) + change = StagedChange.objects.last() + self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE) + self.assertEqual(change.data['name'], provider.name) + self.assertEqual(change.data['comments'], provider.comments) + + with checkout(branch): + + # Delete the staged object + provider = Provider.objects.get(name='Provider D') + provider.delete() + + # Check that a third Change has recorded the object's deletion + self.assertEqual(StagedChange.objects.count(), 3) + change = StagedChange.objects.last() + self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE) + self.assertIsNone(change.data) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e8ee4b7b6..84e899ed2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -14,7 +14,6 @@ from netbox.views import HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site - openapi_info = openapi.Info( title="NetBox API", default_version='v3', @@ -100,5 +99,5 @@ urlpatterns = [ path('{}'.format(settings.BASE_PATH), include(_patterns)) ] -handler404 = 'netbox.views.handler_404' -handler500 = 'netbox.views.server_error' +handler404 = 'netbox.views.errors.handler_404' +handler500 = 'netbox.views.errors.handler_500' diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 18b64344f..d1f1f3d1a 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -1,215 +1,2 @@ -import platform -import sys -from collections import namedtuple - -from django.conf import settings -from django.core.cache import cache -from django.http import HttpResponseServerError -from django.shortcuts import redirect, render -from django.template import loader -from django.template.exceptions import TemplateDoesNotExist -from django.urls import reverse -from django.utils.translation import gettext as _ -from django.views.decorators.csrf import requires_csrf_token -from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found -from django.views.generic import View -from packaging import version -from sentry_sdk import capture_message - -from circuits.models import Circuit, Provider -from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, -) -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF -from netbox.constants import SEARCH_MAX_RESULTS -from netbox.forms import SearchForm -from netbox.search import SEARCH_TYPES -from tenancy.models import Contact, Tenant -from virtualization.models import Cluster, VirtualMachine -from wireless.models import WirelessLAN, WirelessLink - - -Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) - - -class HomeView(View): - template_name = 'home.html' - - def get(self, request): - if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - return redirect('login') - - console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - - def get_count_queryset(model): - return model.objects.restrict(request.user, 'view').count - - def build_stats(): - org = ( - Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), - Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), - Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), - ) - dcim = ( - Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), - Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), - Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), - ) - ipam = ( - Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), - Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), - Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), - Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), - Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), - Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), - ) - circuits = ( - Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), - Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) - ) - virtualization = ( - Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', - get_count_queryset(Cluster)), - Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', - get_count_queryset(VirtualMachine)), - ) - connections = ( - Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), - Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), - Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), - Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), - ) - power = ( - Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), - Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), - ) - wireless = ( - Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', - get_count_queryset(WirelessLAN)), - Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', - get_count_queryset(WirelessLink)), - ) - stats = ( - (_('Organization'), org, 'domain'), - (_('IPAM'), ipam, 'counter'), - (_('Virtualization'), virtualization, 'monitor'), - (_('Inventory'), dcim, 'server'), - (_('Circuits'), circuits, 'transit-connection-variant'), - (_('Connections'), connections, 'cable-data'), - (_('Power'), power, 'flash'), - (_('Wireless'), wireless, 'wifi'), - ) - - return stats - - # Compile changelog table - changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( - 'user', 'changed_object_type' - )[:10] - changelog_table = ObjectChangeTable(changelog, user=request.user) - - # Check whether a new release is available. (Only for staff/superusers.) - new_release = None - if request.user.is_staff or request.user.is_superuser: - latest_release = cache.get('latest_release') - if latest_release: - release_version, release_url = latest_release - if release_version > version.parse(settings.VERSION): - new_release = { - 'version': str(release_version), - 'url': release_url, - } - - return render(request, self.template_name, { - 'search_form': SearchForm(), - 'stats': build_stats(), - 'changelog_table': changelog_table, - 'new_release': new_release, - }) - - -class SearchView(View): - - def get(self, request): - form = SearchForm(request.GET) - results = [] - - if form.is_valid(): - - # If an object type has been specified, redirect to the dedicated view for it - if form.cleaned_data['obj_type']: - object_type = form.cleaned_data['obj_type'] - url = reverse(SEARCH_TYPES[object_type]['url']) - return redirect(f"{url}?q={form.cleaned_data['q']}") - - for obj_type in SEARCH_TYPES.keys(): - - queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view') - filterset = SEARCH_TYPES[obj_type]['filterset'] - table = SEARCH_TYPES[obj_type]['table'] - url = SEARCH_TYPES[obj_type]['url'] - - # Construct the results table for this object type - filtered_queryset = filterset({'q': form.cleaned_data['q']}, queryset=queryset).qs - table = table(filtered_queryset, orderable=False) - table.paginate(per_page=SEARCH_MAX_RESULTS) - - if table.page: - results.append({ - 'name': queryset.model._meta.verbose_name_plural, - 'table': table, - 'url': f"{reverse(url)}?q={form.cleaned_data.get('q')}" - }) - - return render(request, 'search.html', { - 'form': form, - 'results': results, - }) - - -class StaticMediaFailureView(View): - """ - Display a user-friendly error message with troubleshooting tips when a static media file fails to load. - """ - def get(self, request): - return render(request, 'media_failure.html', { - 'filename': request.GET.get('filename') - }) - - -def handler_404(request, exception): - """ - Wrap Django's default 404 handler to enable Sentry reporting. - """ - capture_message("Page not found", level="error") - - return page_not_found(request, exception) - - -@requires_csrf_token -def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): - """ - Custom 500 handler to provide additional context when rendering 500.html. - """ - try: - template = loader.get_template(template_name) - except TemplateDoesNotExist: - return HttpResponseServerError('

Server Error (500)

', content_type='text/html') - type_, error, traceback = sys.exc_info() - - return HttpResponseServerError(template.render({ - 'error': error, - 'exception': str(type_), - 'netbox_version': settings.VERSION, - 'python_version': platform.python_version(), - })) +from .misc import * +from .errors import * diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py new file mode 100644 index 000000000..c74c67cef --- /dev/null +++ b/netbox/netbox/views/errors.py @@ -0,0 +1,56 @@ +import platform +import sys + +from django.conf import settings +from django.http import HttpResponseServerError +from django.shortcuts import render +from django.template import loader +from django.template.exceptions import TemplateDoesNotExist +from django.views.decorators.csrf import requires_csrf_token +from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found +from django.views.generic import View +from sentry_sdk import capture_message + +__all__ = ( + 'handler_404', + 'handler_500', + 'StaticMediaFailureView', +) + + +class StaticMediaFailureView(View): + """ + Display a user-friendly error message with troubleshooting tips when a static media file fails to load. + """ + def get(self, request): + return render(request, 'media_failure.html', { + 'filename': request.GET.get('filename') + }) + + +def handler_404(request, exception): + """ + Wrap Django's default 404 handler to enable Sentry reporting. + """ + capture_message("Page not found", level="error") + + return page_not_found(request, exception) + + +@requires_csrf_token +def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): + """ + Custom 500 handler to provide additional context when rendering 500.html. + """ + try: + template = loader.get_template(template_name) + except TemplateDoesNotExist: + return HttpResponseServerError('

Server Error (500)

', content_type='text/html') + type_, error, traceback = sys.exc_info() + + return HttpResponseServerError(template.render({ + 'error': error, + 'exception': str(type_), + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), + })) diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3ad3bcf67..778a54e10 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -1,12 +1,18 @@ +from django.core.exceptions import ImproperlyConfigured from django.shortcuts import get_object_or_404 from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin +__all__ = ( + 'BaseObjectView', + 'BaseMultiObjectView', +) + class BaseObjectView(ObjectPermissionRequiredMixin, View): """ - Base view class for reusable generic views. + Base class for generic views which display or manipulate a single object. Attributes: queryset: Django QuerySet from which the object(s) will be fetched @@ -15,6 +21,24 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): queryset = None template_name = None + def dispatch(self, request, *args, **kwargs): + self.queryset = self.get_queryset(request) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, request): + """ + Return the base queryset for the view. By default, this returns `self.queryset.all()`. + + Args: + request: The current request + """ + if self.queryset is None: + raise ImproperlyConfigured( + f"{self.__class__.__name__} does not define a queryset. Set queryset on the class or " + f"override its get_queryset() method." + ) + return self.queryset.all() + def get_object(self, **kwargs): """ Return the object being viewed or modified. The object is identified by an arbitrary set of keyword arguments @@ -37,7 +61,7 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): """ - Base view class for reusable generic views. + Base class for generic views which display or manipulate multiple objects. Attributes: queryset: Django QuerySet from which the object(s) will be fetched @@ -48,6 +72,24 @@ class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): table = None template_name = None + def dispatch(self, request, *args, **kwargs): + self.queryset = self.get_queryset(request) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, request): + """ + Return the base queryset for the view. By default, this returns `self.queryset.all()`. + + Args: + request: The current request + """ + if self.queryset is None: + raise ImproperlyConfigured( + f"{self.__class__.__name__} does not define a queryset. Set queryset on the class or " + f"override its get_queryset() method." + ) + return self.queryset.all() + def get_extra_context(self, request): """ Return any additional context data to include when rendering the template. diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index f0741af2c..75416f097 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,23 +4,22 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput +from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django_tables2.export import TableExport from django.utils.safestring import mark_safe +from django_tables2.export import TableExport from extras.models import ExportTemplate from extras.signals import clear_webhooks +from utilities.choices import ImportFormatChoices from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, PermissionsViolation -from utilities.forms import ( - BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, -) +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin @@ -126,7 +125,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): content_type = ContentType.objects.get_for_model(model) if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs + self.queryset = self.filterset(request.GET, self.queryset, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user) @@ -142,7 +141,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render an ExportTemplate elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export']) return self.export_template(template, request) # Check for YAML export support on the model @@ -295,71 +294,135 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ template_name = 'generic/bulk_import.html' model_form = None - - def _import_form(self, *args, **kwargs): - - class ImportForm(BootstrapMixin, Form): - csv = CSVDataField( - from_form=self.model_form - ) - csv_file = CSVFileField( - label="CSV file", - from_form=self.model_form, - required=False - ) - - def clean(self): - csv_rows = self.cleaned_data['csv'][1] if 'csv' in self.cleaned_data else None - csv_file = self.files.get('csv_file') - - # Check that the user has not submitted both text data and a file - if csv_rows and csv_file: - raise ValidationError( - "Cannot process CSV text and file attachment simultaneously. Please choose only one import " - "method." - ) - - return ImportForm(*args, **kwargs) - - def _create_objects(self, form, request): - new_objs = [] - if request.FILES: - headers, records = form.cleaned_data['csv_file'] - else: - headers, records = form.cleaned_data['csv'] - - for row, data in enumerate(records, start=1): - obj_form = self.model_form(data, headers=headers) - restrict_form_fields(obj_form, request.user) - - if obj_form.is_valid(): - obj = self._save_obj(obj_form, request) - new_objs.append(obj) - else: - for field, err in obj_form.errors.items(): - form.add_error('csv', f'Row {row} {field}: {err[0]}') - raise ValidationError("") - - return new_objs - - def _save_obj(self, obj_form, request): - """ - Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). - """ - return obj_form.save() + related_object_forms = dict() def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def prep_related_object_data(self, parent, data): + """ + Hook to modify the data for related objects before it's passed to the related object form (for example, to + assign a parent object). + """ + return data + + def _save_object(self, model_form, request): + + # Save the primary object + obj = self.save_object(model_form, request) + + # Enforce object-level permissions + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() + + # Iterate through the related object forms (if any), validating and saving each instance. + for field_name, related_object_form in self.related_object_forms.items(): + + related_obj_pks = [] + for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): + rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) + f = related_object_form(rel_obj_data) + + for subfield_name, field in f.fields.items(): + if subfield_name not in rel_obj_data and hasattr(field, 'initial'): + f.data[subfield_name] = field.initial + + if f.is_valid(): + related_obj = f.save() + related_obj_pks.append(related_obj.pk) + else: + # Replicate errors on the related object form to the primary form for display + for subfield_name, errors in f.errors.items(): + for err in errors: + err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) + model_form.add_error(None, err_msg) + raise AbortTransaction() + + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist + + return obj + + def save_object(self, object_form, request): + """ + Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). + + Args: + object_form: The model form instance + request: The current request + """ + return object_form.save() + + def create_and_update_objects(self, form, request): + saved_objects = [] + + records = list(form.cleaned_data['data']) + + # Prefetch objects to be updated, if any + prefetch_ids = [int(record['id']) for record in records if record.get('id')] + prefetched_objects = { + obj.pk: obj + for obj in self.queryset.model.objects.filter(id__in=prefetch_ids) + } if prefetch_ids else {} + + for i, record in enumerate(records, start=1): + instance = None + object_id = int(record.pop('id')) if record.get('id') else None + + # Determine whether this object is being created or updated + if object_id: + try: + instance = prefetched_objects[object_id] + except KeyError: + form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist") + raise ValidationError('') + + # Instantiate the model form for the object + model_form_kwargs = { + 'data': record, + 'instance': instance, + } + if form.cleaned_data['format'] == ImportFormatChoices.CSV: + model_form_kwargs['headers'] = form._csv_headers + model_form = self.model_form(**model_form_kwargs) + + # When updating, omit all form fields other than those specified in the record. (No + # fields are required when modifying an existing object.) + if object_id: + unused_fields = [f for f in model_form.fields if f not in record] + for field_name in unused_fields: + del model_form.fields[field_name] + + restrict_form_fields(model_form, request.user) + + if model_form.is_valid(): + obj = self._save_object(model_form, request) + saved_objects.append(obj) + else: + # Replicate model form errors for display + for field, errors in model_form.errors.items(): + for err in errors: + if field == '__all__': + form.add_error(None, f'Record {i}: {err}') + else: + form.add_error(None, f'Record {i} {field}: {err}') + + raise ValidationError("") + + return saved_objects + # # Request handlers # def get(self, request): + form = ImportForm() return render(request, self.template_name, { 'model': self.model_form._meta.model, - 'form': self._import_form(), + 'form': form, 'fields': self.model_form().fields, 'return_url': self.get_return_url(request), **self.get_extra_context(request), @@ -367,15 +430,16 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): def post(self, request): logger = logging.getLogger('netbox.views.BulkImportView') - form = self._import_form(request.POST, request.FILES) + + form = ImportForm(request.POST, request.FILES) if form.is_valid(): - logger.debug("Form validation was successful") + logger.debug("Import form validation was successful") try: - # Iterate through CSV data and bind each row to a new model form instance. + # Iterate through data and bind each record to a new model form instance. with transaction.atomic(): - new_objs = self._create_objects(form, request) + new_objs = self.create_and_update_objects(form, request) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): @@ -394,7 +458,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): 'return_url': self.get_return_url(request), }) - except ValidationError: + except (AbortTransaction, ValidationError): clear_webhooks.send(sender=self) except (AbortRequest, PermissionsViolation) as e: @@ -501,7 +565,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: - pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs + pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs else: pk_list = request.POST.getlist('pk') @@ -698,7 +762,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if request.POST.get('_all'): qs = model.objects.all() if self.filterset is not None: - qs = self.filterset(request.GET, qs).qs + qs = self.filterset(request.GET, qs, request=request).qs pk_list = qs.only('pk').values_list('pk', flat=True) else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -785,7 +849,8 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filterset is not None: - pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs] + queryset = self.filterset(request.GET, self.parent_model.objects.only('pk'), request=request).qs + pk_list = [obj.pk for obj in queryset] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 85e675a69..d4d02ee4e 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -1,10 +1,12 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django.utils.translation import gettext as _ from django.views.generic import View from extras import forms, tables from extras.models import * +from utilities.views import ViewTab __all__ = ( 'ObjectChangeLogView', @@ -23,6 +25,11 @@ class ObjectChangeLogView(View): base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used. """ base_template = None + tab = ViewTab( + label=_('Changelog'), + permission='extras.view_objectchange', + weight=10000 + ) def get(self, request, model, **kwargs): @@ -56,7 +63,7 @@ class ObjectChangeLogView(View): 'object': obj, 'table': objectchanges_table, 'base_template': self.base_template, - 'active_tab': 'changelog', + 'tab': self.tab, }) @@ -71,6 +78,12 @@ class ObjectJournalView(View): base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used. """ base_template = None + tab = ViewTab( + label=_('Journal'), + badge=lambda obj: obj.journal_entries.count(), + permission='extras.view_journalentry', + weight=9000 + ) def get(self, request, model, **kwargs): @@ -111,5 +124,5 @@ class ObjectJournalView(View): 'form': form, 'table': journalentry_table, 'base_template': self.base_template, - 'active_tab': 'journal', + 'tab': self.tab, }) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 15869e04c..795f4ad56 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -2,10 +2,8 @@ import logging from copy import deepcopy from django.contrib import messages -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError -from django.forms.widgets import HiddenInput from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape @@ -13,8 +11,8 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation -from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields +from utilities.exceptions import AbortRequest, PermissionsViolation +from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields @@ -28,7 +26,6 @@ __all__ = ( 'ObjectChildrenView', 'ObjectDeleteView', 'ObjectEditView', - 'ObjectImportView', 'ObjectView', ) @@ -38,7 +35,12 @@ class ObjectView(BaseObjectView): Retrieve a single object for display. Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. + + Attributes: + tab: A ViewTab instance for the view """ + tab = None + def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -67,6 +69,7 @@ class ObjectView(BaseObjectView): return render(request, self.get_template_name(), { 'object': instance, + 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -121,7 +124,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): child_objects = self.get_children(request, instance) if self.filterset: - child_objects = self.filterset(request.GET, child_objects).qs + child_objects = self.filterset(request.GET, child_objects, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) @@ -142,150 +145,11 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): 'child_model': self.child_model, 'table': table, 'actions': actions, + 'tab': self.tab, **self.get_extra_context(request, instance), }) -class ObjectImportView(GetReturnURLMixin, BaseObjectView): - """ - Import a single object (YAML or JSON format). - - Attributes: - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - """ - template_name = 'generic/object_import.html' - model_form = None - related_object_forms = dict() - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - - def prep_related_object_data(self, parent, data): - """ - Hook to modify the data for related objects before it's passed to the related object form (for example, to - assign a parent object). - """ - return data - - def _create_object(self, model_form): - - # Save the primary object - obj = model_form.save() - - # Enforce object-level permissions - if not self.queryset.filter(pk=obj.pk).exists(): - raise PermissionsViolation() - - # Iterate through the related object forms (if any), validating and saving each instance. - for field_name, related_object_form in self.related_object_forms.items(): - - related_obj_pks = [] - for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): - rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) - f = related_object_form(rel_obj_data) - - for subfield_name, field in f.fields.items(): - if subfield_name not in rel_obj_data and hasattr(field, 'initial'): - f.data[subfield_name] = field.initial - - if f.is_valid(): - related_obj = f.save() - related_obj_pks.append(related_obj.pk) - else: - # Replicate errors on the related object form to the primary form for display - for subfield_name, errors in f.errors.items(): - for err in errors: - err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) - model_form.add_error(None, err_msg) - raise AbortTransaction() - - # Enforce object-level permissions on related objects - model = related_object_form.Meta.model - if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): - raise ObjectDoesNotExist - - return obj - - # - # Request handlers - # - - def get(self, request): - form = ImportForm() - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - def post(self, request): - logger = logging.getLogger('netbox.views.ObjectImportView') - form = ImportForm(request.POST) - - if form.is_valid(): - logger.debug("Import form validation was successful") - - # Initialize model form - data = form.cleaned_data['data'] - model_form = self.model_form(data) - restrict_form_fields(model_form, request.user) - - # Assign default values for any fields which were not specified. We have to do this manually because passing - # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not - # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the - # applicable field defaults as needed prior to form validation. - for field_name, field in model_form.fields.items(): - if field_name not in data and hasattr(field, 'initial'): - model_form.data[field_name] = field.initial - - if model_form.is_valid(): - - try: - with transaction.atomic(): - obj = self._create_object(model_form) - - except AbortTransaction: - clear_webhooks.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) - clear_webhooks.send(sender=self) - - if not model_form.errors: - logger.info(f"Import object {obj} (PK: {obj.pk})") - msg = f'Imported object: {obj}' - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - - self.get_return_url(request, obj) - return redirect(self.get_return_url(request, obj)) - - else: - logger.debug("Model form validation failed") - - # Replicate model form errors for display - for field, errors in model_form.errors.items(): - for err in errors: - if field == '__all__': - form.add_error(None, err) - else: - form.add_error(None, "{}: {}".format(field, err)) - - else: - logger.debug("Import form validation failed") - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ Create or edit a single object. diff --git a/netbox/netbox/views/generic/utils.py b/netbox/netbox/views/generic/utils.py index 61c6dc242..61d73e811 100644 --- a/netbox/netbox/views/generic/utils.py +++ b/netbox/netbox/views/generic/utils.py @@ -1,12 +1,13 @@ +from django.apps import apps + + def get_prerequisite_model(queryset): - model = queryset.model - + """ + Return any prerequisite model that must be created prior to creating + an instance of the current model. + """ if not queryset.exists(): - if hasattr(model, 'get_prerequisite_models'): - prerequisites = model.get_prerequisite_models() - if prerequisites: - for prereq in prerequisites: - if not prereq.objects.exists(): - return prereq - - return None + for prereq in getattr(queryset.model, 'prerequisite_models', []): + model = apps.get_model(prereq) + if not model.objects.exists(): + return model diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py new file mode 100644 index 000000000..adf539bbe --- /dev/null +++ b/netbox/netbox/views/misc.py @@ -0,0 +1,183 @@ +from collections import namedtuple + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.shortcuts import redirect, render +from django.utils.translation import gettext as _ +from django.views.generic import View +from django_tables2 import RequestConfig +from packaging import version + +from circuits.models import Circuit, Provider +from dcim.models import ( + Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, +) +from extras.models import ObjectChange +from extras.tables import ObjectChangeTable +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF +from netbox.forms import SearchForm +from netbox.search import LookupTypes +from netbox.search.backends import search_backend +from netbox.tables import SearchTable +from tenancy.models import Contact, Tenant +from utilities.htmx import is_htmx +from utilities.paginator import EnhancedPaginator, get_paginate_count +from virtualization.models import Cluster, VirtualMachine +from wireless.models import WirelessLAN, WirelessLink + +__all__ = ( + 'HomeView', + 'SearchView', +) + +Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) + + +class HomeView(View): + template_name = 'home.html' + + def get(self, request): + if settings.LOGIN_REQUIRED and not request.user.is_authenticated: + return redirect('login') + + console_connections = ConsolePort.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + power_connections = PowerPort.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + interface_connections = Interface.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + + def get_count_queryset(model): + return model.objects.restrict(request.user, 'view').count + + def build_stats(): + org = ( + Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), + Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), + Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), + ) + dcim = ( + Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), + Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), + Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), + ) + ipam = ( + Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), + Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), + Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), + Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), + Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), + Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), + ) + circuits = ( + Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), + Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) + ) + virtualization = ( + Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', + get_count_queryset(Cluster)), + Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', + get_count_queryset(VirtualMachine)), + ) + connections = ( + Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), + Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), + Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), + Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), + ) + power = ( + Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), + Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), + ) + wireless = ( + Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', + get_count_queryset(WirelessLAN)), + Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', + get_count_queryset(WirelessLink)), + ) + stats = ( + (_('Organization'), org, 'domain'), + (_('IPAM'), ipam, 'counter'), + (_('Virtualization'), virtualization, 'monitor'), + (_('Inventory'), dcim, 'server'), + (_('Circuits'), circuits, 'transit-connection-variant'), + (_('Connections'), connections, 'cable-data'), + (_('Power'), power, 'flash'), + (_('Wireless'), wireless, 'wifi'), + ) + + return stats + + # Compile changelog table + changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( + 'user', 'changed_object_type' + )[:10] + changelog_table = ObjectChangeTable(changelog, user=request.user) + + # Check whether a new release is available. (Only for staff/superusers.) + new_release = None + if request.user.is_staff or request.user.is_superuser: + latest_release = cache.get('latest_release') + if latest_release: + release_version, release_url = latest_release + if release_version > version.parse(settings.VERSION): + new_release = { + 'version': str(release_version), + 'url': release_url, + } + + return render(request, self.template_name, { + 'search_form': SearchForm(), + 'stats': build_stats(), + 'changelog_table': changelog_table, + 'new_release': new_release, + }) + + +class SearchView(View): + + def get(self, request): + results = [] + highlight = None + + # Initialize search form + form = SearchForm(request.GET) if 'q' in request.GET else SearchForm() + + if form.is_valid(): + + # Restrict results by object type + object_types = [] + for obj_type in form.cleaned_data['obj_types']: + app_label, model_name = obj_type.split('.') + object_types.append(ContentType.objects.get_by_natural_key(app_label, model_name)) + + lookup = form.cleaned_data['lookup'] or LookupTypes.PARTIAL + results = search_backend.search( + form.cleaned_data['q'], + user=request.user, + object_types=object_types, + lookup=lookup + ) + + if form.cleaned_data['lookup'] != LookupTypes.EXACT: + highlight = form.cleaned_data['q'] + + table = SearchTable(results, highlight=highlight) + + # Paginate the table results + RequestConfig(request, { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + }).configure(table) + + # If this is an HTMX request, return only the rendered table HTML + if is_htmx(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + return render(request, 'search.html', { + 'form': form, + 'table': table, + }) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 1213d719f..19cdae0bd 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 90b87a262..d0563b9fc 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index d711150ed..f19b879fe 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -1,6 +1,6 @@ import { initForms } from './forms'; import { initBootstrap } from './bs'; -import { initSearch } from './search'; +import { initQuickSearch } from './search'; import { initSelect } from './select'; import { initButtons } from './buttons'; import { initColorMode } from './colorMode'; @@ -20,7 +20,7 @@ function initDocument(): void { initColorMode, initMessages, initForms, - initSearch, + initQuickSearch, initSelect, initDateSelector, initButtons, diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts index 97fe1826a..e3bdc18dc 100644 --- a/netbox/project-static/src/search.ts +++ b/netbox/project-static/src/search.ts @@ -1,31 +1,4 @@ -import { getElements, findFirstAdjacent, isTruthy } from './util'; - -/** - * Change the display value and hidden input values of the search filter based on dropdown - * selection. - * - * @param event "click" event for each dropdown item. - * @param button Each dropdown item element. - */ -function handleSearchDropdownClick(event: Event, button: HTMLButtonElement): void { - const dropdown = event.currentTarget as HTMLButtonElement; - const selectedValue = findFirstAdjacent(dropdown, 'span.search-obj-selected'); - const selectedType = findFirstAdjacent(dropdown, 'input.search-obj-type'); - const searchValue = dropdown.getAttribute('data-search-value'); - let selected = '' as string; - - if (selectedValue !== null && selectedType !== null) { - if (isTruthy(searchValue) && selected !== searchValue) { - selected = searchValue; - selectedValue.innerHTML = button.textContent ?? 'Error'; - selectedType.value = searchValue; - } else { - selected = ''; - selectedValue.innerHTML = 'All Objects'; - selectedType.value = ''; - } - } -} +import { isTruthy } from './util'; /** * Show/hide quicksearch clear button. @@ -44,23 +17,10 @@ function quickSearchEventHandler(event: Event): void { } } -/** - * Initialize Search Bar Elements. - */ -function initSearchBar(): void { - for (const dropdown of getElements('.search-obj-selector')) { - for (const button of dropdown.querySelectorAll( - 'li > button.dropdown-item', - )) { - button.addEventListener('click', event => handleSearchDropdownClick(event, button)); - } - } -} - /** * Initialize Quicksearch Event listener/handlers. */ -function initQuickSearch(): void { +export function initQuickSearch(): void { const quicksearch = document.getElementById("quicksearch") as HTMLInputElement; const clearbtn = document.getElementById("quicksearch_clear") as HTMLButtonElement; if (isTruthy(quicksearch)) { @@ -82,10 +42,3 @@ function initQuickSearch(): void { } } } - -export function initSearch(): void { - for (const func of [initSearchBar]) { - func(); - } - initQuickSearch(); -} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index e4db1a1f5..e060110fb 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -1,7 +1,6 @@ {# Base layout for the core NetBox UI w/navbar and page content #} {% extends 'base/base.html' %} {% load helpers %} -{% load search %} {% load static %} {% comment %} @@ -41,7 +40,7 @@ Blocks:
- {% search_options request %} + {% include 'inc/searchbar.html' %}
@@ -53,7 +52,7 @@ Blocks: {# Search bar #}
- {% search_options request %} + {% include 'inc/searchbar.html' %}
{# Proflie/login button #} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 60bf8cfbc..51f911350 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -19,17 +19,6 @@
Provider
- - - - - - - - - - - - - - + + diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e032d7034..bd0f27106 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -32,6 +32,10 @@ + + + + - + + + + + @@ -151,6 +155,38 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} +
+
Virtual Device Contexts
+
+ {% if vdcs %} +
ASN - {% if object.asn %} -
- -
- {% endif %} - {{ object.asn|placeholder }} -
ASNs @@ -45,22 +34,8 @@ {{ object.account|placeholder }}
Customer Portal - {% if object.portal_url %} - {{ object.portal_url }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
NOC Contact{{ object.noc_contact|markdown|placeholder }}
Admin Contact{{ object.admin_contact|markdown|placeholder }}Description{{ object.description|placeholder }}
CircuitsLabel {{ object.label|placeholder }}
Description{{ object.description|placeholder }}
Color @@ -57,6 +61,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 29bb60d70..1c747b44b 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -80,6 +80,7 @@ {% render_field form.tenant_group %} {% render_field form.tenant %} {% render_field form.label %} + {% render_field form.description %} {% render_field form.color %}
@@ -92,16 +93,22 @@
{% render_field form.tags %} - {% if form.custom_fields %} -
-
-
Custom Fields
-
- {% render_custom_fields form %} -
- {% endif %}
+
+
Comments
+
+ {% render_field form.comments %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 0080c8cf3..aa31db97c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -94,7 +94,11 @@
AirflowDescription{{ object.description|placeholder }}
Airflow {{ object.get_airflow_display|placeholder }}
+ + + + + + + {% for vdc in vdcs %} + + + + + + + {% endfor %} +
NameStatusIdentifierTenant
{{ vdc|linkify }}{% badge vdc.get_status_display bg_color=vdc.get_status_color %}{{ vdc.identifier|placeholder }}{{ vdc.tenant|linkify|placeholder }}
+ {% else %} +
None
+ {% endif %} +
+ {% if perms.dcim.add_virtualdevicecontext %} + + {% endif %} + {% plugin_left_page object %}
@@ -264,35 +300,11 @@
{% endif %} -
-
- Services -
-
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} -
- None -
- {% endif %} -
- {% if perms.ipam.add_service %} - - {% endif %} -
+ {% include 'inc/panels/services.html' %} {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% if object.rack and object.position %} -
+

Front

@@ -305,7 +317,7 @@ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
-
+
{% endif %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index ea67154b1..71baa46c3 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -54,113 +54,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bays' modulebay_count=object.modulebays.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interfaces' interface_count=object.interfaces_count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-ports' frontport_count=object.frontports.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-ports' rearport_count=object.rearports.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-ports' consoleport_count=object.consoleports.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-ports' powerport_count=object.powerports.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - - {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} - - {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %} - {# NAPALM-enabled tabs #} - - - - {% endif %} - - {% if perms.extras.view_configcontext %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 38125e83c..b814e65ef 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -10,6 +10,7 @@ {% render_field form.name %} {% render_field form.device_role %} + {% render_field form.description %} {% render_field form.tags %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 1fde72d27..930390a56 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,6 +27,10 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Height (U) {{ object.u_height|floatformat }} @@ -35,6 +39,16 @@ Full Depth {% checkmark object.is_full_depth %} + + Weight + + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Parent/Child diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 83ee1f41e..916952dfb 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -51,85 +51,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-port-templates' frontport_count=object.frontporttemplates.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-port-templates' rearport_count=object.rearporttemplates.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-port-templates' consoleport_count=object.consoleporttemplates.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-port-templates' consoleserverport_count=object.consoleserverporttemplates.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-port-templates' powerport_count=object.powerporttemplates.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlet-templates' poweroutlet_count=object.poweroutlettemplates.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 49078c8db..b593b7c00 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -116,6 +116,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
Addressing
diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f2dac38f2..78d5a1a05 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -62,6 +62,14 @@ Module Type {{ object.module_type|linkify }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 2c8e77be3..fd0148c2f 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,20 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + + + Weight + + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Instances {{ instance_count }} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index f5713efc3..148effec2 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -42,61 +42,3 @@
{% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfacetemplates.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - - {% with frontport_count=object.frontporttemplates.count %} - {% if frontport_count %} - - {% endif %} - {% endwith %} - - {% with rearport_count=object.rearporttemplates.count %} - {% if rearport_count %} - - {% endif %} - {% endwith %} - - {% with consoleport_count=object.consoleporttemplates.count %} - {% if consoleport_count %} - - {% endif %} - {% endwith %} - - {% with consoleserverport_count=object.consoleserverporttemplates.count %} - {% if consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with powerport_count=object.powerporttemplates.count %} - {% if powerport_count %} - - {% endif %} - {% endwith %} - - {% with poweroutlet_count=object.poweroutlettemplates.count %} - {% if poweroutlet_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 54ac96bab..6387c111d 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -38,6 +38,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Connected Device diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b7fe8eb39..16bd82cc0 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -14,26 +14,29 @@ {% block content %}
-
-
- Power Panel -
-
- - - - - - - - - -
Site{{ object.site|linkify }}
Location{{ object.location|linkify|placeholder }}
-
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
+
Power Panel
+
+ + + + + + + + + + + + + +
Site{{ object.site|linkify }}
Location{{ object.location|linkify|placeholder }}
Description{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 87ece4169..e2cb1597e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -78,6 +78,10 @@ Role {{ object.role|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} @@ -104,9 +108,7 @@
-
- Dimensions -
+
Dimensions
@@ -131,7 +133,7 @@ + + + + + + + + + + + + + + + +
Outer Width {% if object.outer_width %} - {{ object.outer_width }} {{ object.get_outer_unit_display }} + {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} {{ ''|placeholder }} {% endif %} @@ -141,12 +143,49 @@ Outer Depth {% if object.outer_depth %} - {{ object.outer_depth }} {{ object.get_outer_unit_display }} + {{ object.outer_depth }} {{ object.get_outer_unit_display }} {% else %} {{ ''|placeholder }} {% endif %}
Mounting Depth + {% if object.mounting_depth %} + {{ object.mounting_depth }} Millimeters + {% else %} + {{ ''|placeholder }} + {% endif %} +
Rack Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Maximum Weight + {% if object.max_weight %} + {{ object.max_weight }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Total Weight + {{ object.total_weight|floatformat }} Kilograms + ({{ object.total_weight|kg_to_pounds|floatformat }} Pounds) +
@@ -186,6 +225,7 @@
{% endif %} + {% include 'inc/panels/image_attachments.html' %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index ca97be34d..cd9ed637a 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -13,6 +13,7 @@ {% render_field form.name %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.description %} {% render_field form.tags %}
@@ -55,6 +56,22 @@
Unit
+
+ +
+ {{ form.weight }} +
Weight
+
+
+ {{ form.max_weight }} +
Maximum Weight
+
+
+ {{ form.weight_unit }} +
Unit
+
+
+ {% render_field form.mounting_depth %} {% render_field form.desc_units %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ebdd1d845..52472e297 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -73,6 +73,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 1ff9f2e9a..d0fba3ca2 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -27,11 +27,15 @@ Master {{ object.master|linkify }} + + Description + {{ object.description|placeholder }} +
- {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
@@ -73,6 +77,7 @@
{% endif %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 87917f2a2..f98a9fe64 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -17,12 +17,18 @@ {% render_field vc_form.name %} {% render_field vc_form.domain %} + {% render_field vc_form.description %} {% render_field vc_form.master %} {% render_field vc_form.tags %} +
+
Comments
+ {% render_field vc_form.comments %} +
+ {% if vc_form.custom_fields %} -
+
Custom Fields
diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html new file mode 100644 index 000000000..c7b2b9659 --- /dev/null +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -0,0 +1,76 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+
+
+ Virtual Device Context +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Device{{ object.device|linkify }}
Identifier{{ object.identifier|placeholder }}
Primary IPv4 + {{ object.primary_ip4|linkify|placeholder }} +
Primary IPv6 + {{ object.primary_ip6|linkify|placeholder }} +
Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Interfaces
+
+ {% render_table interfaces_table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index ff4e6e08c..4350bb738 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -39,13 +39,23 @@ {% checkmark object.required %} - Weight - {{ object.weight }} + Search Weight + + {% if object.search_weight %} + {{ object.search_weight }} + {% else %} + Disabled + {% endif %} + Filter Logic {{ object.get_filter_logic_display }} + + Display Weight + {{ object.weight }} + UI Visibility {{ object.get_ui_visibility_display }} diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 1f3866182..ff0f7423e 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -6,19 +6,13 @@
-
- Custom Link -
+
Custom Link
- - - - @@ -42,6 +36,18 @@
Name {{ object.name }}
Content Type{{ object.content_type }}
Enabled {% checkmark object.enabled %}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 912702b86..d14294355 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -18,10 +18,6 @@
- - - - @@ -45,6 +41,18 @@
Content Type{{ object.content_type }}
Name {{ object.name }}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}
diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index c20bf5fe2..ddf2c94f4 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -1,7 +1,14 @@ +{% load humanize %} {% load helpers %}

- Initiated: {{ result.created|annotated_date }} + {% if result.started %} + Started: {{ result.started|annotated_date }} + {% elif result.scheduled %} + Scheduled for: {{ result.scheduled|annotated_date }} ({{ result.scheduled|naturaltime }}) + {% else %} + Created: {{ result.created|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% endif %} @@ -68,6 +75,6 @@

-{% else %} +{% elif result.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 425f35898..ca2d278d3 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -2,7 +2,13 @@ {% load log_levels %}

- Initiated: {{ result.created|annotated_date }} + {% if result.started %} + Started: {{ result.started|annotated_date }} + {% elif result.scheduled %} + Scheduled for: {{ result.scheduled|annotated_date }} ({{ result.scheduled|naturaltime }}) + {% else %} + Created: {{ result.created|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% endif %} @@ -45,6 +51,6 @@ {% else %}

None

{% endif %} -{% else %} +{% elif result.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/inc/job_label.html b/netbox/templates/extras/inc/job_label.html index d74931111..7ff788ede 100644 --- a/netbox/templates/extras/inc/job_label.html +++ b/netbox/templates/extras/inc/job_label.html @@ -4,6 +4,8 @@ Errored {% elif result.status == 'pending' %} Pending +{% elif result.status == 'scheduled' %} + Scheduled {% elif result.status == 'running' %} Running {% elif result.status == 'completed' %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 391de6614..a3876d319 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -1,5 +1,6 @@ {% extends 'generic/object.html' %} {% load helpers %} +{% load form_helpers %} {% block title %}{{ report.name }}{% endblock %} @@ -33,27 +34,32 @@ {% block content %}
{% if perms.extras.run_report %} -
-
- {% csrf_token %} +
+
+ + {% csrf_token %} + {% render_form form %} +
+
+
{% endif %}
-
- {% if report.result %} - Last run: - {{ report.result.created|annotated_date }} - - {% endif %} -
+
+ {% if report.result %} + Last run: + {{ report.result.created|annotated_date }} + + {% endif %} +
{% endblock content %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index b4a0c0f12..ffa52f9b7 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -1,9 +1,21 @@ {% extends 'extras/report.html' %} +{% load buttons %} +{% load perms %} {% block content-wrapper %}
-
+
{% include 'extras/htmx/report_result.html' %}
{% endblock %} + +{% block controls %} +
+
+ {% if request.user|can_delete:result %} + {% delete_button result %} + {% endif %} +
+
+{% endblock controls %} \ No newline at end of file diff --git a/netbox/templates/extras/savedfilter.html b/netbox/templates/extras/savedfilter.html new file mode 100644 index 000000000..fc851f487 --- /dev/null +++ b/netbox/templates/extras/savedfilter.html @@ -0,0 +1,70 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Saved Filter
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
User{{ object.user|placeholder }}
Enabled{% checkmark object.enabled %}
Shared{% checkmark object.shared %}
Weight{{ object.weight }}
+
+
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Parameters +
+
+
{{ object.parameters|json }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 99eade0a0..6fbcde322 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -43,7 +43,7 @@ You do not have permission to run scripts.
{% endif %} -
+ {% csrf_token %}
{% if form.requires_input %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 1c311ff26..bff3fc61e 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -1,5 +1,7 @@ {% extends 'base/layout.html' %} {% load helpers %} +{% load buttons %} +{% load perms %} {% block title %}{{ script }}{% endblock %} @@ -23,6 +25,16 @@ {{ block.super }} {% endblock header %} +{% block controls %} +
+
+ {% if request.user|can_delete:result %} + {% delete_button result %} + {% endif %} +
+
+{% endblock controls %} + {% block content-wrapper %}
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index b4911ce44..a74ddac70 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -26,6 +26,10 @@ Group ID {{ object.group_id }} + + Name + {{ object.name|placeholder }} + Description {{ object.description|placeholder }} @@ -38,6 +42,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index 858d265ab..bf86e6c41 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -8,11 +8,12 @@
{% render_field form.protocol %} {% render_field form.group_id %} + {% render_field form.name %} {% render_field form.description %} {% render_field form.tags %} -
+
Authentication
@@ -21,7 +22,7 @@
{% if not form.instance.pk %} -
+
Virtual IP Address
@@ -31,6 +32,13 @@
{% endif %} +
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html deleted file mode 100644 index 0d6faf1dc..000000000 --- a/netbox/templates/ipam/inc/service.html +++ /dev/null @@ -1,28 +0,0 @@ - - {{ service|linkify:"name" }} - {{ service.get_protocol_display }} - {{ service.port_list }} - - {% for ip in service.ipaddresses.all %} - {{ ip.address.ip }}
- {% empty %} - All IPs - {% endfor %} - - {{ service.description }} - - - - - {% if perms.ipam.change_service %} - - - - {% endif %} - {% if perms.ipam.delete_service %} - - - - {% endif %} - - diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7f77e8137..131087253 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -108,6 +108,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -137,24 +138,7 @@
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
-
-
- Services -
-
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} -
- None -
- {% endif %} -
-
+ {% include 'inc/panels/services.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index f4b21397a..b9a988009 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -138,6 +138,13 @@ +
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index c78b5a132..6ba9e4bea 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -70,9 +70,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/iprange/base.html b/netbox/templates/ipam/iprange/base.html index 30e858264..e97db8557 100644 --- a/netbox/templates/ipam/iprange/base.html +++ b/netbox/templates/ipam/iprange/base.html @@ -8,13 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_ipaddress %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index c19363d33..4ffda2c98 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -39,6 +39,7 @@
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index b15aa60bb..a0baf3325 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -155,6 +155,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/prefix/base.html b/netbox/templates/ipam/prefix/base.html index b543e37ac..7ac307014 100644 --- a/netbox/templates/ipam/prefix/base.html +++ b/netbox/templates/ipam/prefix/base.html @@ -8,21 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - - - -{% endblock %} diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index e093aee61..ea7a98c97 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -26,6 +26,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 47ae70dc9..fdc4be342 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -58,9 +58,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html index 022821bcf..5c47dd2f8 100644 --- a/netbox/templates/ipam/service_create.html +++ b/netbox/templates/ipam/service_create.html @@ -65,6 +65,13 @@ {% render_field form.tags %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index f3e34a7d1..709d816c1 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -52,6 +52,13 @@ {% render_field form.tags %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/servicetemplate.html b/netbox/templates/ipam/servicetemplate.html index 6e2aacb34..afb4163b9 100644 --- a/netbox/templates/ipam/servicetemplate.html +++ b/netbox/templates/ipam/servicetemplate.html @@ -31,12 +31,13 @@
{% plugin_left_page object %} - -
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} -
+ +
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 53bb75b8f..c0f68bae2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -74,9 +74,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan/base.html b/netbox/templates/ipam/vlan/base.html index 42c6c0347..031ae6517 100644 --- a/netbox/templates/ipam/vlan/base.html +++ b/netbox/templates/ipam/vlan/base.html @@ -13,27 +13,3 @@ {% endif %} {% endblock %} - -{% block tabs %} - -{% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 5aa577942..f4432efe3 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -55,6 +55,13 @@ {% endwith %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 831338600..b53862f9e 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -55,6 +55,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/search.html b/netbox/templates/search.html index a47b48b09..e801422c9 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -15,74 +15,24 @@ {% endblock tabs %} -{% block content-wrapper %} -
- {% if request.GET.q %} - {% if results %} -
-
- {% for obj_type in results %} -
-
{{ obj_type.name|bettertitle }}
-
- {% render_table obj_type.table 'inc/table.html' %} -
- -
- {% endfor %} -
-
-
-
- Search Results -
- -
-
-
- {% else %} -

No results found

- {% endif %} - {% else %} -
-
-
-
-
- Search -
-
- {% render_form form %} -
- -
-
-
+{% block content %} +
+
+
+ {% render_form form %} +
+
- {% endif %} +
+
-{% endblock content-wrapper %} +
+
+
+ {% include 'htmx/table.html' %} +
+
+
+{% endblock content %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8e71628e9..d92226137 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -63,6 +63,10 @@ {% endif %} + + Description + {{ object.description|placeholder }} + Assignments {{ assignment_count }} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 7d2ff0149..c13145f42 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -61,6 +61,10 @@

{{ stats.device_count }}

Devices

+
+

{{ stats.vdc_count }}

+

Virtual Device Contexts

+

{{ stats.cable_count }}

Cables

diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/api_token.html new file mode 100644 index 000000000..1a9296704 --- /dev/null +++ b/netbox/templates/users/api_token.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load form_helpers %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+ {% if not settings.ALLOW_TOKEN_RETRIEVAL %} + + {% endif %} +
+
Token
+
+ + + + + + + + + + + + + + + + + + + + + +
Key +
+ + + +
+
{{ key }}
+
Description{{ object.description|placeholder }}
User{{ object.user }}
Created{{ object.created|annotated_date }}
Expires + {% if object.expires %} + {{ object.expires|annotated_date }} + {% else %} + Never + {% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index bc02424cc..510c5a48e 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -23,6 +23,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Group {{ object.group|linkify|placeholder }} diff --git a/netbox/templates/virtualization/cluster/base.html b/netbox/templates/virtualization/cluster/base.html index 69b55ec6b..eb9eefe0e 100644 --- a/netbox/templates/virtualization/cluster/base.html +++ b/netbox/templates/virtualization/cluster/base.html @@ -24,20 +24,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with virtualmachine_count=object.virtual_machines.count %} - - {% endwith %} - {% with device_count=object.devices.count %} - - {% endwith %} -{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index c0e2ebd07..9b5708486 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -29,6 +29,10 @@ Platform {{ object.platform|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Tenant @@ -140,29 +144,7 @@
-
-
- Services -
-
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} - None - {% endif %} -
- {% if perms.ipam.add_service %} - - {% endif %} -
+ {% include 'inc/panels/services.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 946467e31..995c16fb0 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -21,18 +21,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfaces.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - {% if perms.extras.view_configcontext %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 9250ef7ef..ad76f9c07 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -18,6 +18,10 @@ Group {{ object.group|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Description {{ object.description|placeholder }} @@ -39,6 +43,7 @@ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index d1a93e40d..be98979c1 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html index 034d147de..462ae5148 100644 --- a/netbox/templates/wireless/wirelesslink_edit.html +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -22,6 +22,12 @@
+
+
+
Comments
+
+ {% render_field form.comments %} +
{% if form.custom_fields %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index d2c6801c6..c8ef77117 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -85,8 +85,8 @@ class ContactSerializer(NetBoxModelSerializer): class Meta: model = Contact fields = [ - 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py index 53cb9a056..eeb141152 100644 --- a/netbox/tenancy/apps.py +++ b/netbox/tenancy/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class TenancyConfig(AppConfig): name = 'tenancy' + + def ready(self): + from . import search diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index dd14a412b..ab74949ff 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.db.models import Q +from django.utils.translation import gettext as _ from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter @@ -25,13 +26,13 @@ __all__ = ( class ContactGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - label='Contact group (ID)', + label=_('Contact group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=ContactGroup.objects.all(), to_field_name='slug', - label='Contact group (slug)', + label=_('Contact group (slug)'), ) class Meta: @@ -51,14 +52,14 @@ class ContactFilterSet(NetBoxModelFilterSet): queryset=ContactGroup.objects.all(), field_name='group', lookup_expr='in', - label='Contact group (ID)', + label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='group', lookup_expr='in', to_field_name='slug', - label='Contact group (slug)', + label=_('Contact group (slug)'), ) class Meta: @@ -83,17 +84,17 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): content_type = ContentTypeFilter() contact_id = django_filters.ModelMultipleChoiceFilter( queryset=Contact.objects.all(), - label='Contact (ID)', + label=_('Contact (ID)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactRole.objects.all(), - label='Contact role (ID)', + label=_('Contact role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=ContactRole.objects.all(), to_field_name='slug', - label='Contact role (slug)', + label=_('Contact role (slug)'), ) class Meta: @@ -105,18 +106,18 @@ class ContactModelFilterSet(django_filters.FilterSet): contact = django_filters.ModelMultipleChoiceFilter( field_name='contacts__contact', queryset=Contact.objects.all(), - label='Contact', + label=_('Contact'), ) contact_role = django_filters.ModelMultipleChoiceFilter( field_name='contacts__role', queryset=ContactRole.objects.all(), - label='Contact Role' + label=_('Contact Role') ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='contacts__contact__group', lookup_expr='in', - label='Contact group', + label=_('Contact group'), ) @@ -127,13 +128,13 @@ class ContactModelFilterSet(django_filters.FilterSet): class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), - label='Tenant group (ID)', + label=_('Tenant group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) class Meta: @@ -146,14 +147,14 @@ class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=TenantGroup.objects.all(), field_name='group', lookup_expr='in', - label='Tenant group (ID)', + label=_('Tenant group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='group', lookup_expr='in', to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) class Meta: @@ -179,22 +180,22 @@ class TenancyFilterSet(django_filters.FilterSet): queryset=TenantGroup.objects.all(), field_name='tenant__group', lookup_expr='in', - label='Tenant Group (ID)', + label=_('Tenant Group (ID)'), ) tenant_group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='tenant__group', to_field_name='slug', lookup_expr='in', - label='Tenant Group (slug)', + label=_('Tenant Group (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), - label='Tenant (ID)', + label=_('Tenant (ID)'), ) tenant = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), field_name='tenant__slug', to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) diff --git a/netbox/tenancy/forms/__init__.py b/netbox/tenancy/forms/__init__.py index 61f0bc961..96c1e50f7 100644 --- a/netbox/tenancy/forms/__init__.py +++ b/netbox/tenancy/forms/__init__.py @@ -1,5 +1,5 @@ from .forms import * -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 4c1f03757..183a8e851 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import DynamicModelChoiceField +from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea __all__ = ( 'ContactBulkEditForm', @@ -101,9 +101,17 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): link = forms.URLField( required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Contact fieldsets = ( - (None, ('group', 'title', 'phone', 'email', 'address', 'link')), + (None, ('group', 'title', 'phone', 'email', 'address', 'link', 'description')), ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'comments') + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index d617a27b5..8a251a316 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,13 +1,14 @@ -from netbox.forms import NetBoxModelCSVForm +from django.utils.translation import gettext as _ +from netbox.forms import NetBoxModelImportForm from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField __all__ = ( - 'ContactCSVForm', - 'ContactGroupCSVForm', - 'ContactRoleCSVForm', - 'TenantCSVForm', - 'TenantGroupCSVForm', + 'ContactImportForm', + 'ContactGroupImportForm', + 'ContactRoleImportForm', + 'TenantImportForm', + 'TenantGroupImportForm', ) @@ -15,53 +16,53 @@ __all__ = ( # Tenants # -class TenantGroupCSVForm(NetBoxModelCSVForm): +class TenantGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Parent group' + help_text=_('Parent group') ) slug = SlugField() class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') -class TenantCSVForm(NetBoxModelCSVForm): +class TenantImportForm(NetBoxModelImportForm): slug = SlugField() group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) class Meta: model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments') + fields = ('name', 'slug', 'group', 'description', 'comments', 'tags') # # Contacts # -class ContactGroupCSVForm(NetBoxModelCSVForm): +class ContactGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text='Parent group' + help_text=_('Parent group') ) slug = SlugField() class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') -class ContactRoleCSVForm(NetBoxModelCSVForm): +class ContactRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -69,14 +70,14 @@ class ContactRoleCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'description') -class ContactCSVForm(NetBoxModelCSVForm): +class ContactImportForm(NetBoxModelImportForm): group = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 02589d733..c5d7fca0c 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( - (None, ('q', 'tag', 'group_id')), + (None, ('q', 'filter_id', 'tag', 'group_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/model_forms.py similarity index 97% rename from netbox/tenancy/forms/models.py rename to netbox/tenancy/forms/model_forms.py index 80af04928..b466c94b2 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/model_forms.py @@ -103,13 +103,13 @@ class ContactForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'tags')), + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags')), ) class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 77297b982..e8a028a92 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -54,7 +54,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index 35e568ab1..eb247ee29 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -19,14 +19,14 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -55,7 +55,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('title', models.CharField(blank=True, max_length=100)), diff --git a/netbox/tenancy/migrations/0008_unique_constraints.py b/netbox/tenancy/migrations/0008_unique_constraints.py new file mode 100644 index 000000000..092878524 --- /dev/null +++ b/netbox/tenancy/migrations/0008_unique_constraints.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_contact_link'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='contact', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactgroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='contact', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_contact_unique_group_name'), + ), + migrations.AddConstraint( + model_name='contactassignment', + constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'contact', 'role'), name='tenancy_contactassignment_unique_object_contact_role'), + ), + migrations.AddConstraint( + model_name='contactgroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'), + ), + ] diff --git a/netbox/tenancy/migrations/0009_standardize_description_comments.py b/netbox/tenancy/migrations/0009_standardize_description_comments.py new file mode 100644 index 000000000..af93b055c --- /dev/null +++ b/netbox/tenancy/migrations/0009_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0008_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 1dba814a6..4fa8d87cb 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -2,9 +2,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import WebhooksMixin from tenancy.choices import * @@ -20,29 +19,13 @@ class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - class Meta: ordering = ['name'] - unique_together = ( - ('parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_unique_parent_name' + ), ) def get_absolute_url(self): @@ -53,30 +36,11 @@ class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('tenancy:contactrole', args=[self.pk]) -class Contact(NetBoxModel): +class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ @@ -108,9 +72,6 @@ class Contact(NetBoxModel): link = models.URLField( blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'group', 'name', 'title', 'phone', 'email', 'address', 'link', @@ -118,8 +79,11 @@ class Contact(NetBoxModel): class Meta: ordering = ['name'] - unique_together = ( - ('group', 'name') + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), ) def __str__(self): @@ -159,7 +123,12 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('priority', 'contact') - unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') + constraints = ( + models.UniqueConstraint( + fields=('content_type', 'object_id', 'contact', 'role'), + name='%(app_label)s_%(class)s_unique_object_contact_role' + ), + ) def __str__(self): if self.priority: diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index b0ccd1cb2..4c0c11e2a 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -1,9 +1,8 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( 'Tenant', @@ -23,18 +22,6 @@ class TenantGroup(NestedGroupModel): max_length=100, unique=True ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ['name'] @@ -43,7 +30,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -class Tenant(NetBoxModel): +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. @@ -63,13 +50,6 @@ class Tenant(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py new file mode 100644 index 000000000..bee497608 --- /dev/null +++ b/netbox/tenancy/search.py @@ -0,0 +1,58 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class ContactIndex(SearchIndex): + model = models.Contact + fields = ( + ('name', 100), + ('title', 300), + ('phone', 300), + ('email', 300), + ('address', 300), + ('link', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ContactGroupIndex(SearchIndex): + model = models.ContactGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class ContactRoleIndex(SearchIndex): + model = models.ContactRole + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class TenantIndex(SearchIndex): + model = models.Tenant + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class TenantGroupIndex(SearchIndex): + model = models.TenantGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 8cb6c0700..cdc21f30b 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -68,8 +68,8 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'comments', 'assignment_count', 'tags', - 'created', 'last_updated', + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'assignment_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 881802a7b..0ac5b16d4 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -32,6 +32,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tenant Group 6,tenant-group-6,Sixth tenant group", ) + cls.csv_update_data = ( + "id,name,description", + f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", + f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", + f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -50,11 +57,12 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): for tenanantgroup in tenant_groups: tenanantgroup.save() - Tenant.objects.bulk_create([ + tenants = ( Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]), Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), - ]) + ) + Tenant.objects.bulk_create(tenants) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -74,6 +82,13 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Tenant 6,tenant-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{tenants[0].pk},Tenant 7,New description 7", + f"{tenants[1].pk},Tenant 8,New description 8", + f"{tenants[2].pk},Tenant 9,New description 9", + ) + cls.bulk_edit_data = { 'group': tenant_groups[1].pk, } @@ -109,6 +124,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Contact Group 6,contact-group-6,Sixth contact group", ) + cls.csv_update_data = ( + "id,name,description", + f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", + f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", + f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -120,11 +142,12 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ContactRole.objects.bulk_create([ + contact_roles = ( ContactRole(name='Contact Role 1', slug='contact-role-1'), ContactRole(name='Contact Role 2', slug='contact-role-2'), ContactRole(name='Contact Role 3', slug='contact-role-3'), - ]) + ) + ContactRole.objects.bulk_create(contact_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -142,6 +165,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Contact Role 6,contact-role-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{contact_roles[0].pk},Contact Role 7,New description 7", + f"{contact_roles[1].pk},Contact Role 8,New description 8", + f"{contact_roles[2].pk},Contact Role 9,New description 9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -160,11 +190,12 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): for contactgroup in contact_groups: contactgroup.save() - Contact.objects.bulk_create([ + contacts = ( Contact(name='Contact 1', group=contact_groups[0]), Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 3', group=contact_groups[0]), - ]) + ) + Contact.objects.bulk_create(contacts) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -182,6 +213,13 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Contact Group 1,Contact 6", ) + cls.csv_update_data = ( + "id,name,comments", + f"{contacts[0].pk},Contact Group 7,New comments 7", + f"{contacts[1].pk},Contact Group 8,New comments 8", + f"{contacts[2].pk},Contact Group 9,New comments 9", + ) + cls.bulk_edit_data = { 'group': contact_groups[1].pk, } diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 214100275..3b5addaec 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'tenancy' urlpatterns = [ @@ -13,10 +12,7 @@ urlpatterns = [ path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), - path('tenant-groups//', views.TenantGroupView.as_view(), name='tenantgroup'), - path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), - path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), - path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path('tenant-groups//', include(get_model_urls('tenancy', 'tenantgroup'))), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), @@ -24,11 +20,7 @@ urlpatterns = [ path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), - path('tenants//', views.TenantView.as_view(), name='tenant'), - path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), - path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), - path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), - path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), + path('tenants//', include(get_model_urls('tenancy', 'tenant'))), # Contact groups path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'), @@ -36,10 +28,7 @@ urlpatterns = [ path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'), path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'), path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'), - path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'), - path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'), - path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'), - path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}), + path('contact-groups//', include(get_model_urls('tenancy', 'contactgroup'))), # Contact roles path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'), @@ -47,10 +36,7 @@ urlpatterns = [ path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'), path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'), path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'), - path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'), - path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'), - path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'), - path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}), + path('contact-roles//', include(get_model_urls('tenancy', 'contactrole'))), # Contacts path('contacts/', views.ContactListView.as_view(), name='contact_list'), @@ -58,15 +44,10 @@ urlpatterns = [ path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'), path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'), path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'), - path('contacts//', views.ContactView.as_view(), name='contact'), - path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'), - path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'), - path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), - path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + path('contacts//', include(get_model_urls('tenancy', 'contact'))), # Contact assignments path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), - path('contact-assignments//edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'), - path('contact-assignments//delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'), + path('contact-assignments//', include(get_model_urls('tenancy', 'contactassignment'))), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 03b454321..f4c9b6d04 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,12 +1,12 @@ from django.contrib.contenttypes.models import ContentType -from django.http import QueryDict from django.shortcuts import get_object_or_404 from circuits.models import Circuit -from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF, ASN +from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext +from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic from utilities.utils import count_related +from utilities.views import register_model_view from virtualization.models import VirtualMachine, Cluster from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables @@ -30,6 +30,7 @@ class TenantGroupListView(generic.ObjectListView): table = tables.TenantGroupTable +@register_model_view(TenantGroup) class TenantGroupView(generic.ObjectView): queryset = TenantGroup.objects.all() @@ -45,18 +46,20 @@ class TenantGroupView(generic.ObjectView): } +@register_model_view(TenantGroup, 'edit') class TenantGroupEditView(generic.ObjectEditView): queryset = TenantGroup.objects.all() form = forms.TenantGroupForm +@register_model_view(TenantGroup, 'delete') class TenantGroupDeleteView(generic.ObjectDeleteView): queryset = TenantGroup.objects.all() class TenantGroupBulkImportView(generic.BulkImportView): queryset = TenantGroup.objects.all() - model_form = forms.TenantGroupCSVForm + model_form = forms.TenantGroupImportForm table = tables.TenantGroupTable @@ -95,6 +98,7 @@ class TenantListView(generic.ObjectListView): table = tables.TenantTable +@register_model_view(Tenant) class TenantView(generic.ObjectView): queryset = Tenant.objects.all() @@ -105,6 +109,7 @@ class TenantView(generic.ObjectView): 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), @@ -126,18 +131,20 @@ class TenantView(generic.ObjectView): } +@register_model_view(Tenant, 'edit') class TenantEditView(generic.ObjectEditView): queryset = Tenant.objects.all() form = forms.TenantForm +@register_model_view(Tenant, 'delete') class TenantDeleteView(generic.ObjectDeleteView): queryset = Tenant.objects.all() class TenantBulkImportView(generic.BulkImportView): queryset = Tenant.objects.all() - model_form = forms.TenantCSVForm + model_form = forms.TenantImportForm table = tables.TenantTable @@ -171,6 +178,7 @@ class ContactGroupListView(generic.ObjectListView): table = tables.ContactGroupTable +@register_model_view(ContactGroup) class ContactGroupView(generic.ObjectView): queryset = ContactGroup.objects.all() @@ -201,18 +209,20 @@ class ContactGroupView(generic.ObjectView): } +@register_model_view(ContactGroup, 'edit') class ContactGroupEditView(generic.ObjectEditView): queryset = ContactGroup.objects.all() form = forms.ContactGroupForm +@register_model_view(ContactGroup, 'delete') class ContactGroupDeleteView(generic.ObjectDeleteView): queryset = ContactGroup.objects.all() class ContactGroupBulkImportView(generic.BulkImportView): queryset = ContactGroup.objects.all() - model_form = forms.ContactGroupCSVForm + model_form = forms.ContactGroupImportForm table = tables.ContactGroupTable @@ -251,6 +261,7 @@ class ContactRoleListView(generic.ObjectListView): table = tables.ContactRoleTable +@register_model_view(ContactRole) class ContactRoleView(generic.ObjectView): queryset = ContactRole.objects.all() @@ -268,18 +279,20 @@ class ContactRoleView(generic.ObjectView): } +@register_model_view(ContactRole, 'edit') class ContactRoleEditView(generic.ObjectEditView): queryset = ContactRole.objects.all() form = forms.ContactRoleForm +@register_model_view(ContactRole, 'delete') class ContactRoleDeleteView(generic.ObjectDeleteView): queryset = ContactRole.objects.all() class ContactRoleBulkImportView(generic.BulkImportView): queryset = ContactRole.objects.all() - model_form = forms.ContactRoleCSVForm + model_form = forms.ContactRoleImportForm table = tables.ContactRoleTable @@ -308,6 +321,7 @@ class ContactListView(generic.ObjectListView): table = tables.ContactTable +@register_model_view(Contact) class ContactView(generic.ObjectView): queryset = Contact.objects.all() @@ -325,18 +339,20 @@ class ContactView(generic.ObjectView): } +@register_model_view(Contact, 'edit') class ContactEditView(generic.ObjectEditView): queryset = Contact.objects.all() form = forms.ContactForm +@register_model_view(Contact, 'delete') class ContactDeleteView(generic.ObjectDeleteView): queryset = Contact.objects.all() class ContactBulkImportView(generic.BulkImportView): queryset = Contact.objects.all() - model_form = forms.ContactCSVForm + model_form = forms.ContactImportForm table = tables.ContactTable @@ -361,6 +377,7 @@ class ContactBulkDeleteView(generic.BulkDeleteView): # Contact assignments # +@register_model_view(ContactAssignment, 'edit') class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() form = forms.ContactAssignmentForm @@ -380,5 +397,6 @@ class ContactAssignmentEditView(generic.ObjectEditView): } +@register_model_view(ContactAssignment, 'delete') class ContactAssignmentDeleteView(generic.ObjectDeleteView): queryset = ContactAssignment.objects.all() diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 540735ecc..986ddd0aa 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import Group, User from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError +from django.utils.translation import gettext as _ from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import ObjectPermission, Token @@ -46,7 +47,7 @@ class GroupAdminForm(forms.ModelForm): class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, - help_text="If no key is provided, one will be generated automatically." + help_text=_("If no key is provided, one will be generated automatically.") ) class Meta: @@ -70,10 +71,10 @@ class ObjectPermissionForm(forms.ModelForm): model = ObjectPermission exclude = [] help_texts = { - 'actions': 'Actions granted in addition to those listed above', - 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.' + 'actions': _('Actions granted in addition to those listed above'), + 'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.') } labels = { 'actions': 'Additional actions' diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1ec3528f7..f1f1fc975 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers @@ -63,7 +64,13 @@ class GroupSerializer(ValidatedModelSerializer): class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') - key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) + key = serializers.CharField( + min_length=40, + max_length=40, + allow_blank=True, + required=False, + write_only=not settings.ALLOW_TOKEN_RETRIEVAL + ) user = NestedUserSerializer() allowed_ips = serializers.ListField( child=IPNetworkSerializer(), diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 66ef92ab7..86a66a01f 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -88,6 +88,8 @@ class TokenProvisionView(APIView): token = Token(user=user) token.save() data = serializers.TokenSerializer(token, context={'request': request}).data + # Manually append the token key, which is normally write-only + data['key'] = token.key return Response(data, status=HTTP_201_CREATED) diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 71246956b..4ae9df89a 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,6 +1,7 @@ import django_filters from django.contrib.auth.models import Group, User from django.db.models import Q +from django.utils.translation import gettext as _ from netbox.filtersets import BaseFilterSet from users.models import ObjectPermission, Token @@ -15,7 +16,7 @@ __all__ = ( class GroupFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) class Meta: @@ -31,18 +32,18 @@ class GroupFilterSet(BaseFilterSet): class UserFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) group_id = django_filters.ModelMultipleChoiceFilter( field_name='groups', queryset=Group.objects.all(), - label='Group', + label=_('Group'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='groups__name', queryset=Group.objects.all(), to_field_name='name', - label='Group (name)', + label=_('Group (name)'), ) class Meta: @@ -63,18 +64,18 @@ class UserFilterSet(BaseFilterSet): class TokenFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='user', queryset=User.objects.all(), - label='User', + label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) created = django_filters.DateTimeFilter() created__gte = django_filters.DateTimeFilter( @@ -111,29 +112,29 @@ class TokenFilterSet(BaseFilterSet): class ObjectPermissionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=User.objects.all(), - label='User', + label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='users__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) group_id = django_filters.ModelMultipleChoiceFilter( field_name='groups', queryset=Group.objects.all(), - label='Group', + label=_('Group'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='groups__name', queryset=Group.objects.all(), to_field_name='name', - label='Group (name)', + label=_('Group (name)'), ) class Meta: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index b4e86461d..e8647aa5f 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms +from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from django.utils.translation import gettext as _ from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES @@ -99,14 +101,14 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( required=False, - help_text="If no key is provided, one will be generated automatically." + help_text=_("If no key is provided, one will be generated automatically.") ) allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(), required=False, - label='Allowed IPs', - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64', + label=_('Allowed IPs'), + help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64'), ) class Meta: @@ -117,3 +119,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm): widgets = { 'expires': DateTimePicker(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Omit the key field if token retrieval is not permitted + if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: + del self.fields['key'] diff --git a/netbox/users/models.py b/netbox/users/models.py index 4ee4dce6b..07e903569 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,6 +1,7 @@ import binascii import os +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField @@ -9,6 +10,7 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.utils.translation import gettext as _ from netaddr import IPNetwork from ipam.fields import IPNetworkField @@ -215,7 +217,7 @@ class Token(models.Model): ) write_enabled = models.BooleanField( default=True, - help_text='Permit create/update/delete operations using this key' + help_text=_('Permit create/update/delete operations using this key') ) description = models.CharField( max_length=200, @@ -226,16 +228,16 @@ class Token(models.Model): blank=True, null=True, verbose_name='Allowed IPs', - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'), ) - class Meta: - pass - def __str__(self): - # Only display the last 24 bits of the token to avoid accidental exposure. - return f"{self.key[-6:]} ({self.user})" + return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + + @property + def partial(self): + return f'**********************************{self.key[-6:]}' if self.key else '' def save(self, *args, **kwargs): if not self.key: @@ -303,12 +305,12 @@ class ObjectPermission(models.Model): ) actions = ArrayField( base_field=models.CharField(max_length=30), - help_text="The list of actions granted by this permission" + help_text=_("The list of actions granted by this permission") ) constraints = models.JSONField( blank=True, null=True, - help_text="Queryset filter matching the applicable objects of the selected type(s)" + help_text=_("Queryset filter matching the applicable objects of the selected type(s)") ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 27547b955..8fbe9e8b3 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -6,14 +6,16 @@ __all__ = ( ) -TOKEN = """{{ value }}""" +TOKEN = """{{ record }}""" ALLOWED_IPS = """{{ value|join:", " }}""" COPY_BUTTON = """ - - - +{% if settings.ALLOW_TOKEN_RETRIEVAL %} + + + +{% endif %} """ @@ -38,5 +40,5 @@ class TokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description', + 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 48d440278..7a2337f33 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -4,7 +4,8 @@ from django.test import TestCase class UserConfigTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): user = User.objects.create_user(username='testuser') user.config.data = { @@ -27,10 +28,8 @@ class UserConfigTest(TestCase): } user.config.save() - self.userconfig = user.config - def test_get(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Retrieve root and nested values self.assertEqual(userconfig.get('a'), True) @@ -50,7 +49,7 @@ class UserConfigTest(TestCase): self.assertEqual(userconfig.get('b.foo.x.invalid', 'DEFAULT'), 'DEFAULT') def test_all(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config flattened_data = { 'a': True, 'b.foo': 101, @@ -64,7 +63,7 @@ class UserConfigTest(TestCase): self.assertEqual(userconfig.all(), flattened_data) def test_set(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Overwrite existing values userconfig.set('a', 'abc') @@ -93,7 +92,7 @@ class UserConfigTest(TestCase): userconfig.set('a.x', 1) def test_clear(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Clear existing values userconfig.clear('a') diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 62b17a663..ed1c21c02 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -1,16 +1,19 @@ -from django.urls import path +from django.urls import include, path +from utilities.urls import get_model_urls from . import views app_name = 'users' urlpatterns = [ + # User path('profile/', views.ProfileView.as_view(), name='profile'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), + + # API tokens path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), - path('api-tokens//edit/', views.TokenEditView.as_view(), name='token_edit'), - path('api-tokens//delete/', views.TokenDeleteView.as_view(), name='token_delete'), + path('api-tokens//', include(get_model_urls('users', 'token'))), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index a02f1ae2c..832a4e592 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -20,6 +20,7 @@ from extras.tables import ObjectChangeTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from utilities.forms import ConfirmationForm +from utilities.views import register_model_view from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token, UserConfig from .tables import TokenTable @@ -246,6 +247,7 @@ class TokenListView(LoginRequiredMixin, View): }) +@register_model_view(Token, 'edit') class TokenEditView(LoginRequiredMixin, View): def get(self, request, pk=None): @@ -273,6 +275,7 @@ class TokenEditView(LoginRequiredMixin, View): form = TokenForm(request.POST) if form.is_valid(): + token = form.save(commit=False) token.user = request.user token.save() @@ -280,7 +283,13 @@ class TokenEditView(LoginRequiredMixin, View): msg = f"Modified token {token}" if pk else f"Created token {token}" messages.success(request, msg) - if '_addanother' in request.POST: + if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: + return render(request, 'users/api_token.html', { + 'object': token, + 'key': token.key, + 'return_url': reverse('users:token_list'), + }) + elif '_addanother' in request.POST: return redirect(request.path) else: return redirect('users:token_list') @@ -289,9 +298,11 @@ class TokenEditView(LoginRequiredMixin, View): 'object': token, 'form': form, 'return_url': reverse('users:token_list'), + 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL }) +@register_model_view(Token, 'delete') class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index c5b5bafb9..ea4c5f951 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -197,3 +197,21 @@ class ButtonColorChoices(ChoiceSet): (BLACK, 'Black'), (WHITE, 'White'), ) + + +# +# Import Choices +# + +class ImportFormatChoices(ChoiceSet): + AUTO = 'auto' + CSV = 'csv' + JSON = 'json' + YAML = 'yaml' + + CHOICES = [ + (AUTO, 'Auto-detect'), + (CSV, 'CSV'), + (JSON, 'JSON'), + (YAML, 'YAML'), + ] diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index a9b851def..b2bc4d2cd 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -1,3 +1,6 @@ +from collections import defaultdict + +from django.contrib.contenttypes.fields import GenericForeignKey from django.core.validators import RegexValidator from django.db import models @@ -71,3 +74,70 @@ class NaturalOrderingField(models.CharField): [self.target_field], kwargs, ) + + +class RestrictedGenericForeignKey(GenericForeignKey): + + # Replicated largely from GenericForeignKey. Changes include: + # 1. Capture restrict_params from RestrictedPrefetch (hack) + # 2. If restrict_params is set, call restrict() on the queryset for + # the related model + def get_prefetch_queryset(self, instances, queryset=None): + restrict_params = {} + + # Compensate for the hack in RestrictedPrefetch + if type(queryset) is dict: + restrict_params = queryset + elif queryset is not None: + raise ValueError("Custom queryset can't be used for this lookup.") + + # For efficiency, group the instances by content type and then do one + # query per model + fk_dict = defaultdict(set) + # We need one instance for each group in order to get the right db: + instance_dict = {} + ct_attname = self.model._meta.get_field(self.ct_field).get_attname() + for instance in instances: + # We avoid looking for values if either ct_id or fkey value is None + ct_id = getattr(instance, ct_attname) + if ct_id is not None: + fk_val = getattr(instance, self.fk_field) + if fk_val is not None: + fk_dict[ct_id].add(fk_val) + instance_dict[ct_id] = instance + + ret_val = [] + for ct_id, fkeys in fk_dict.items(): + instance = instance_dict[ct_id] + ct = self.get_content_type(id=ct_id, using=instance._state.db) + if restrict_params: + # Override the default behavior to call restrict() on each model's queryset + qs = ct.model_class().objects.filter(pk__in=fkeys).restrict(**restrict_params) + ret_val.extend(qs) + else: + # Default behavior + ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) + + # For doing the join in Python, we have to match both the FK val and the + # content type, so we use a callable that returns a (fk, class) pair. + def gfk_key(obj): + ct_id = getattr(obj, ct_attname) + if ct_id is None: + return None + else: + model = self.get_content_type( + id=ct_id, using=obj._state.db + ).model_class() + return ( + model._meta.pk.get_prep_value(getattr(obj, self.fk_field)), + model, + ) + + return ( + ret_val, + lambda obj: (obj.pk, obj.__class__), + gfk_key, + True, + self.name, + False, + ) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 543449b73..d41eff498 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,8 +3,6 @@ from django import forms from django.conf import settings from django_filters.constants import EMPTY_VALUES -from utilities.forms import MACAddressField - def multivalue_field_factory(field_class): """ @@ -23,7 +21,15 @@ def multivalue_field_factory(field_class): field.to_python(v) for v in value if v ] - return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + def run_validators(self, value): + for v in value: + super().run_validators(v) + + def validate(self, value): + for v in value: + super().validate(v) + + return type(f'MultiValue{field_class.__name__}', (NewField,), dict()) # @@ -46,6 +52,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.IntegerField) +class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DecimalField) + + class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField) diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index 275c8084c..f964d0db0 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -5,6 +5,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import Q +from django.utils.translation import gettext as _ from utilities.choices import unpack_grouped_choices from utilities.forms.utils import parse_csv, validate_csv @@ -16,6 +17,7 @@ __all__ = ( 'CSVDataField', 'CSVFileField', 'CSVModelChoiceField', + 'CSVModelMultipleChoiceField', 'CSVMultipleChoiceField', 'CSVMultipleContentTypeField', 'CSVTypedChoiceField', @@ -49,9 +51,9 @@ class CSVDataField(forms.CharField): if not self.initial: self.initial = ','.join(self.required_fields) + '\n' if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' + self.help_text = _('Enter the list of column headers followed by one line per record to be imported, using ' + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' + 'in double quotes.') def to_python(self, value): reader = csv.reader(StringIO(value.strip())) @@ -142,7 +144,7 @@ class CSVModelChoiceField(forms.ModelChoiceField): Extends Django's `ModelChoiceField` to provide additional validation for CSV values. """ default_error_messages = { - 'invalid_choice': 'Object not found.', + 'invalid_choice': 'Object not found: %(value)s', } def to_python(self, value): @@ -154,6 +156,19 @@ class CSVModelChoiceField(forms.ModelChoiceField): ) +class CSVModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + Extends Django's `ModelMultipleChoiceField` to support comma-separated values. + """ + default_error_messages = { + 'invalid_choice': 'Object not found: %(value)s', + } + + def clean(self, value): + value = value.split(',') if value else [] + return super().clean(value) + + class CSVContentTypeField(CSVModelChoiceField): """ CSV field for referencing a single content type, in the form `.`. diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py index fca370c26..781de9f76 100644 --- a/netbox/utilities/forms/fields/expandable.py +++ b/netbox/utilities/forms/fields/expandable.py @@ -1,6 +1,7 @@ import re from django import forms +from django.utils.translation import gettext as _ from utilities.forms.constants import * from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern @@ -42,8 +43,8 @@ class ExpandableIPAddressField(forms.CharField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
'\ - 'Example: 192.0.2.[1,5,100-254]/24' + self.help_text = _('Specify a numeric range to create multiple IPs.
' + 'Example: 192.0.2.[1,5,100-254]/24') def to_python(self, value): # Hackish address family detection but it's all we have to work with diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index df69339e5..bb6c3f73b 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -4,6 +4,7 @@ from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.templatetags.static import static +from django.utils.translation import gettext as _ from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -45,7 +46,7 @@ class SlugField(forms.SlugField): slug_source: Name of the form field from which the slug value will be derived """ widget = widgets.SlugWidget - help_text = "URL-friendly unique shorthand" + help_text = _("URL-friendly unique shorthand") def __init__(self, *, slug_source='name', help_text=help_text, **kwargs): super().__init__(help_text=help_text, **kwargs) @@ -97,7 +98,7 @@ class JSONField(_JSONField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Enter context data in JSON format.' + self.help_text = _('Enter context data in JSON format.') self.widget.attrs['placeholder'] = '' self.widget.attrs['class'] = 'font-monospace' diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 8ad6f103b..99d03f2a6 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -1,12 +1,16 @@ +import csv import json import re +from io import StringIO import yaml from django import forms +from django.utils.translation import gettext as _ +from utilities.choices import ImportFormatChoices +from utilities.forms.utils import parse_csv from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect - __all__ = ( 'BootstrapMixin', 'BulkEditForm', @@ -44,10 +48,16 @@ class BootstrapMixin: ] for field_name, field in self.fields.items(): + css = field.widget.attrs.get('class', '') if field.widget.__class__ not in exempt_widgets: - css = field.widget.attrs.get('class', '') - field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() + field.widget.attrs['class'] = f'{css} form-control' + + elif isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs['class'] = f'{css} form-check-input' + + elif isinstance(field.widget, forms.Select): + field.widget.attrs['class'] = f'{css} form-select' if field.required and not isinstance(field.widget, forms.FileInput): field.widget.attrs['required'] = 'required' @@ -55,13 +65,18 @@ class BootstrapMixin: if 'placeholder' not in field.widget.attrs and field.label is not None: field.widget.attrs['placeholder'] = field.label - if field.widget.__class__ == forms.CheckboxInput: - css = field.widget.attrs.get('class', '') - field.widget.attrs['class'] = ' '.join((css, 'form-check-input')).strip() + def is_valid(self): + is_valid = super().is_valid() - if field.widget.__class__ == forms.Select: - css = field.widget.attrs.get('class', '') - field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip() + # Apply is-invalid CSS class to fields with errors + if not is_valid: + for field_name in self.errors: + # Ignore e.g. __all__ + if field := self.fields.get(field_name): + css = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = f'{css} is-invalid' + + return is_valid # @@ -100,7 +115,7 @@ class BulkRenameForm(BootstrapMixin, forms.Form): use_regex = forms.BooleanField( required=False, initial=True, - label='Use regular expressions' + label=_('Use regular expressions') ) def clean(self): @@ -120,64 +135,141 @@ class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. """ - - def __init__(self, *args, headers=None, **kwargs): + def __init__(self, *args, headers=None, fields=None, **kwargs): + headers = headers or {} + fields = fields or [] super().__init__(*args, **kwargs) # Modify the model form to accommodate any customized to_field_name properties - if headers: - for field, to_field in headers.items(): - if to_field is not None: - self.fields[field].to_field_name = to_field + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + # Omit any fields not specified (e.g. because the form is being used to + # updated rather than create objects) + if fields: + for field in list(self.fields.keys()): + if field not in fields: + del self.fields[field] class ImportForm(BootstrapMixin, forms.Form): - """ - Generic form for creating an object from JSON/YAML data - """ data = forms.CharField( + required=False, widget=forms.Textarea(attrs={'class': 'font-monospace'}), - help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." + help_text=_("Enter object data in CSV, JSON or YAML format.") + ) + data_file = forms.FileField( + label="Data file", + required=False ) format = forms.ChoiceField( - choices=( - ('json', 'JSON'), - ('yaml', 'YAML') - ), - initial='yaml' + choices=ImportFormatChoices, + initial=ImportFormatChoices.AUTO, + widget=StaticSelect() ) + data_field = 'data' + def clean(self): super().clean() - data = self.cleaned_data['data'] - format = self.cleaned_data['format'] - - # Process JSON/YAML data - if format == 'json': - try: - self.cleaned_data['data'] = json.loads(data) - # Check for multiple JSON objects - if type(self.cleaned_data['data']) is not dict: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - except json.decoder.JSONDecodeError as err: - raise forms.ValidationError({ - 'data': "Invalid JSON data: {}".format(err) - }) + # Determine whether we're reading from form data or an uploaded file + if self.cleaned_data['data'] and self.cleaned_data['data_file']: + raise forms.ValidationError("Form data must be empty when uploading a file.") + if 'data_file' in self.files: + self.data_field = 'data_file' + file = self.files.get('data_file') + data = file.read().decode('utf-8') else: - # Check for multiple YAML documents - if '\n---' in data: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - try: - self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) - except yaml.error.YAMLError as err: - raise forms.ValidationError({ - 'data': "Invalid YAML data: {}".format(err) - }) + data = self.cleaned_data['data'] + + # Determine the data format + if self.cleaned_data['format'] == ImportFormatChoices.AUTO: + format = self._detect_format(data) + else: + format = self.cleaned_data['format'] + + # Process data according to the selected format + if format == ImportFormatChoices.CSV: + self.cleaned_data['data'] = self._clean_csv(data) + elif format == ImportFormatChoices.JSON: + self.cleaned_data['data'] = self._clean_json(data) + elif format == ImportFormatChoices.YAML: + self.cleaned_data['data'] = self._clean_yaml(data) + + def _detect_format(self, data): + """ + Attempt to automatically detect the format (CSV, JSON, or YAML) of the given data, or raise + a ValidationError. + """ + try: + if data[0] in ('{', '['): + return ImportFormatChoices.JSON + if data.startswith('---') or data.startswith('- '): + return ImportFormatChoices.YAML + if ',' in data.split('\n', 1)[0]: + return ImportFormatChoices.CSV + except IndexError: + pass + raise forms.ValidationError({ + 'format': _('Unable to detect data format. Please specify.') + }) + + def _clean_csv(self, data): + """ + Clean CSV-formatted data. The first row will be treated as column headers. + """ + stream = StringIO(data.strip()) + reader = csv.reader(stream) + headers, records = parse_csv(reader) + + # Set CSV headers for reference by the model form + self._csv_headers = headers + + return records + + def _clean_json(self, data): + """ + Clean JSON-formatted data. If only a single object is defined, it will be encapsulated as a list. + """ + try: + data = json.loads(data) + # Accommodate for users entering single objects + if type(data) is not list: + data = [data] + return data + except json.decoder.JSONDecodeError as err: + raise forms.ValidationError({ + self.data_field: f"Invalid JSON data: {err}" + }) + + def _clean_yaml(self, data): + """ + Clean YAML-formatted data. Data must be either + a) A single document comprising a list of dictionaries (each representing an object), or + b) Multiple documents, separated with the '---' token + """ + records = [] + try: + for data in yaml.load_all(data, Loader=yaml.SafeLoader): + if type(data) == list: + records.extend(data) + elif type(data) == dict: + records.append(data) + else: + raise forms.ValidationError({ + self.data_field: _( + "Invalid YAML data. Data must be in the form of multiple documents, or a single document " + "comprising a list of dictionaries." + ) + }) + except yaml.error.YAMLError as err: + raise forms.ValidationError({ + self.data_field: f"Invalid YAML data: {err}" + }) + + return records class FilterForm(BootstrapMixin, forms.Form): @@ -186,7 +278,7 @@ class FilterForm(BootstrapMixin, forms.Form): """ q = forms.CharField( required=False, - label='Search' + label=_('Search') ) @@ -200,7 +292,7 @@ class TableConfigForm(BootstrapMixin, forms.Form): widget=forms.SelectMultiple( attrs={'size': 10, 'class': 'form-select'} ), - label='Available Columns' + label=_('Available Columns') ) columns = forms.MultipleChoiceField( choices=[], @@ -208,7 +300,7 @@ class TableConfigForm(BootstrapMixin, forms.Form): widget=forms.SelectMultiple( attrs={'size': 10, 'class': 'form-select'} ), - label='Selected Columns' + label=_('Selected Columns') ) def __init__(self, table, *args, **kwargs): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index a6f037e0b..1a2f62b2e 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -220,7 +220,11 @@ def validate_csv(headers, fields, required_fields): if parsed csv data contains invalid headers or does not contain required headers. """ # Validate provided column headers + is_update = False for field, to_field in headers.items(): + if field == "id": + is_update = True + continue if field not in fields: raise forms.ValidationError(f'Unexpected column header "{field}" found.') if to_field and not hasattr(fields[field], 'to_field_name'): @@ -228,7 +232,8 @@ def validate_csv(headers, fields, required_fields): if to_field and not hasattr(fields[field].queryset.model, to_field): raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - # Validate required fields - for f in required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') + # Validate required fields (if not an update) + if not is_update: + for f in required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 76a4bc607..1802306f1 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -17,6 +17,7 @@ __all__ = ( 'DatePicker', 'DateTimePicker', 'NumericArrayField', + 'SelectDurationWidget', 'SelectSpeedWidget', 'SelectWithPK', 'SlugWidget', @@ -108,6 +109,13 @@ class SelectSpeedWidget(forms.NumberInput): template_name = 'widgets/select_speed.html' +class SelectDurationWidget(forms.NumberInput): + """ + Dropdown to select one of several common options for a time duration (in minutes). + """ + template_name = 'widgets/select_duration.html' + + class NumericArrayField(SimpleArrayField): def clean(self, value): diff --git a/netbox/utilities/json.py b/netbox/utilities/json.py new file mode 100644 index 000000000..5574ff36f --- /dev/null +++ b/netbox/utilities/json.py @@ -0,0 +1,17 @@ +import decimal + +from django.core.serializers.json import DjangoJSONEncoder + +__all__ = ( + 'CustomFieldJSONEncoder', +) + + +class CustomFieldJSONEncoder(DjangoJSONEncoder): + """ + Override Django's built-in JSON encoder to save decimal values as JSON numbers. + """ + def default(self, o): + if isinstance(o, decimal.Decimal): + return float(o) + return super().default(o) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 955a10d64..0e5f1cd5c 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,9 +1,35 @@ -from django.db.models import QuerySet +from django.db.models import Prefetch, QuerySet from users.constants import CONSTRAINT_TOKEN_USER from utilities.permissions import permission_is_exempt, qs_filter_from_constraints +class RestrictedPrefetch(Prefetch): + """ + Extend Django's Prefetch to accept a user and action to be passed to the + `restrict()` method of the related object's queryset. + """ + def __init__(self, lookup, user, action='view', queryset=None, to_attr=None): + self.restrict_user = user + self.restrict_action = action + + super().__init__(lookup, queryset=queryset, to_attr=to_attr) + + def get_current_queryset(self, level): + params = { + 'user': self.restrict_user, + 'action': self.restrict_action, + } + + if qs := super().get_current_queryset(level): + return qs.restrict(**params) + + # Bit of a hack. If no queryset is defined, pass through the dict of restrict() + # kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey + # fields, which do not permit setting a queryset on a Prefetch object. + return params + + class RestrictedQuerySet(QuerySet): def restrict(self, user, action='view'): diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 4c6c46ef0..ec9ceb09a 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -1,127 +1,65 @@ {% load form_helpers %} {% load helpers %} -{% if field|widget_type == 'checkboxinput' %} -
-
-
-
- {{ field }} - -
- {% if field.help_text %} - {{ field.help_text|safe }} - {% endif %} - {% if bulk_nullable %} -
- - -
- {% endif %} -
-
+
-{% elif field|widget_type == 'textarea' and not label %} -
- {% if label %} -
diff --git a/netbox/utilities/templates/helpers/applied_filters.html b/netbox/utilities/templates/helpers/applied_filters.html index 4f22a7c9a..3cf8fe425 100644 --- a/netbox/utilities/templates/helpers/applied_filters.html +++ b/netbox/utilities/templates/helpers/applied_filters.html @@ -10,5 +10,10 @@ Clear all {% endif %} + {% if save_link %} + + Save + + {% endif %}
{% endif %} diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html index 33a476081..f7e3f3dcb 100644 --- a/netbox/utilities/templates/navigation/menu.html +++ b/netbox/utilities/templates/navigation/menu.html @@ -4,12 +4,12 @@ {% for menu, groups in nav_items %}