diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 11b7e9aff..3e3d2ec3a 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.4 + placeholder: v3.3.7 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/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml index 0f87115fc..cb097d579 100644 --- a/.github/ISSUE_TEMPLATE/documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -19,11 +19,15 @@ body: label: Area description: To what section of the documentation does this change primarily pertain? options: - - Installation instructions - - Configuration parameters - - Functionality/features - - REST API - - Administration/development + - Features + - Installation/upgrade + - Getting started + - Configuration + - Customization + - Integrations/API + - Plugins + - Administration + - Development - Other validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bc00a3921..5f0a17aa7 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.4 + placeholder: v3.3.7 validations: required: true - type: dropdown diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 33134cb45..0bbbe90c7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,13 +1,14 @@ ### Fixes: #1234 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f5028cd..1d9692194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: CI on: [push, pull_request] +permissions: + contents: read jobs: build: runs-on: ubuntu-latest @@ -7,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/.github/workflows/lock.yml b/.github/workflows/lock.yml index 9df4bc441..6019cef5d 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -4,6 +4,11 @@ name: 'Lock threads' on: schedule: - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write jobs: lock: @@ -11,7 +16,6 @@ jobs: steps: - uses: dessant/lock-threads@v3 with: - github-token: ${{ github.token }} issue-inactive-days: 90 pr-inactive-days: 30 issue-lock-reason: 'resolved' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 57666417a..ab259af2a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,14 +1,21 @@ # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) name: 'Close stale issues/PRs' + on: schedule: - cron: '0 4 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write jobs: stale: + runs-on: ubuntu-latest steps: - - uses: actions/stale@v5 + - uses: actions/stale@v6 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/base_requirements.txt b/base_requirements.txt index 22106587d..4032bc26e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -68,7 +68,7 @@ drf-yasg[validation] # Django wrapper for Graphene (GraphQL support) # https://github.com/graphql-python/graphene-django -graphene_django +graphene_django<3.0 # WSGI HTTP server # https://gunicorn.org/ @@ -80,7 +80,8 @@ Jinja2 # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown -Markdown +# mkdocs currently requires Markdown v3.3 +Markdown<3.4 # File inclusion plugin for Python-Markdown # https://github.com/cmacmackin/markdown-include diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 4dfc4e14e..3ff44b9cb 100644 --- a/docs/_theme/main.html +++ b/docs/_theme/main.html @@ -2,8 +2,8 @@ {% block site_meta %} {{ super() }} - {# Disable search indexing unless we're building for ReadTheDocs #} - {% if not config.extra.readthedocs %} + {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} + {% if page.canonical_url != 'https://docs.netbox.dev/' %} {% endif %} {% endblock %} diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 6aa363b1a..b8c2b1e11 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 150c32f40..470868ea0 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/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/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/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/3-netbox.md b/docs/installation/3-netbox.md index f42e28deb..353b0ddab 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/6-ldap.md b/docs/installation/6-ldap.md index 163ace70d..ffba6889b 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt ### General Server Configuration !!! info - When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure. + When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog). ```python import ldap @@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo" # Note that this is a NetBox-specific setting which sets: # ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) LDAP_IGNORE_CERT_ERRORS = True + +# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server +# Note that this is a NetBox-specific setting which sets: +# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR) +LDAP_CA_CERT_DIR = '/etc/ssl/certs' + +# Include this setting if you want to validate the LDAP server certificates against your own CA. +# Note that this is a NetBox-specific setting which sets: +# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE) +LDAP_CA_CERT_FILE = '/path/to/example-CA.crt' ``` STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme. 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/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/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..505160d3e 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -65,6 +65,14 @@ 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). + ### 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.) 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/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 e1eefa7a5..dee0d3796 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm): In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below. ::: utilities.forms.ColorField - selection: + options: members: false ::: utilities.forms.CommentField - selection: + options: members: false ::: utilities.forms.JSONField - selection: + options: members: false ::: utilities.forms.MACAddressField - selection: + options: members: false ::: utilities.forms.SlugField - selection: + options: members: false ## Choice Fields ::: utilities.forms.ChoiceField - selection: + options: members: false ::: utilities.forms.MultipleChoiceField - selection: + options: members: false ## Dynamic Object Fields ::: utilities.forms.DynamicModelChoiceField - selection: + options: members: false ::: utilities.forms.DynamicModelMultipleChoiceField - selection: + options: members: false ## Content Type Fields ::: utilities.forms.ContentTypeChoiceField - selection: + options: members: false ::: utilities.forms.ContentTypeMultipleChoiceField - selection: + options: members: false ## CSV Import Fields ::: utilities.forms.CSVChoiceField - selection: + options: members: false ::: utilities.forms.CSVMultipleChoiceField - selection: + options: members: false ::: utilities.forms.CSVModelChoiceField - selection: + options: members: false ::: utilities.forms.CSVContentTypeField - selection: + options: members: false ::: utilities.forms.CSVMultipleContentTypeField - selection: + options: members: false diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md index 0dadf021f..f802e8025 100644 --- a/docs/plugins/development/graphql-api.md +++ b/docs/plugins/development/graphql-api.md @@ -32,11 +32,11 @@ schema = MyQuery NetBox provides two object type classes for use by plugins. ::: netbox.graphql.types.BaseObjectType - selection: + options: members: false ::: netbox.graphql.types.NetBoxObjectType - selection: + options: members: false ## GraphQL Fields @@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins. NetBox provides two field classes for use by plugins. ::: netbox.graphql.fields.ObjectField - selection: + options: members: false ::: netbox.graphql.fields.ObjectListField - selection: + options: members: false diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 98db9e0bb..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`) | @@ -112,6 +116,22 @@ 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 using the `get_plugin_config()` function. For example: + + ```python + 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 16f5dd0df..b3bcb292a 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,6 +49,12 @@ class MyModel(NetBoxModel): ... ``` +### NetBoxModel Properties + +#### `docs_url` + +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 If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md new file mode 100644 index 000000000..e3b861f00 --- /dev/null +++ b/docs/plugins/development/search.md @@ -0,0 +1,28 @@ +# Search + +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/tables.md b/docs/plugins/development/tables.md index 6dccb4ee2..f846139f0 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`. ::: netbox.tables.BooleanColumn - selection: + options: members: false ::: netbox.tables.ChoiceFieldColumn - selection: + options: members: false ::: netbox.tables.ColorColumn - selection: + options: members: false ::: netbox.tables.ColoredLabelColumn - selection: + options: members: false ::: netbox.tables.ContentTypeColumn - selection: + options: members: false ::: netbox.tables.ContentTypesColumn - selection: + options: members: false ::: netbox.tables.MarkdownColumn - selection: + options: members: false ::: netbox.tables.TagColumn - selection: + options: members: false ::: netbox.tables.TemplateColumn - selection: + options: members: - __init__ diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index cabcd7045..45d7064cf 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -82,26 +82,28 @@ 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 - selection: + options: members: - - get_object - get_template_name ::: netbox.views.generic.ObjectEditView - selection: + options: members: - - get_object - alter_object ::: netbox.views.generic.ObjectDeleteView - selection: - members: - - get_object + options: + members: false ::: netbox.views.generic.ObjectChildrenView - selection: + options: members: - get_children - prep_table_data @@ -111,24 +113,28 @@ 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 - selection: + options: members: - get_table - export_table - export_template ::: netbox.views.generic.BulkImportView - selection: + options: members: false ::: netbox.views.generic.BulkEditView - selection: + options: members: false ::: netbox.views.generic.BulkDeleteView - selection: + options: members: - get_form @@ -137,17 +143,43 @@ Below are the class definitions for NetBox's multi-object views. These views han These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path. ::: netbox.views.generic.ObjectChangeLogView - selection: + options: members: - get_form ::: netbox.views.generic.ObjectJournalView - selection: + options: members: - get_form ## Extending Core Views +### Additional Tabs + +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, 'mview', 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 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: * `left_page()` - Inject content on the left side of the page diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 2955e17d5..a693ec1e0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,18 +1,83 @@ # NetBox v3.3 -## v3.3.5 (FUTURE) +## v3.3.8 (FUTURE) + +--- + +## v3.3.7 (2022-11-01) + +### Bug Fixes + +* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions +* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users +* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API +* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments +* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API + +--- + +## v3.3.6 (2022-10-26) + +### Enhancements + +* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug +* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates +* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface +* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH` +* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view + +### Bug Fixes + +* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication +* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth +* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link +* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer +* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms +* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable +* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables +* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests +* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists +* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ +* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view +* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation +* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs +* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list +* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view + +--- + +## v3.3.5 (2022-10-05) + +### Enhancements + +* [#8424](https://github.com/netbox-community/netbox/issues/8424) - Include rack elevation under device view +* [#10352](https://github.com/netbox-community/netbox/issues/10352) - Omit extraneous URL query attributes during search +* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions ### Bug Fixes * [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view +* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments +* [#10423](https://github.com/netbox-community/netbox/issues/10423) - Enforce object type validation when creating journal entries * [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field +* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values +* [#10460](https://github.com/netbox-community/netbox/issues/10460) - Restore missing connection details for device components +* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI +* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms +* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window +* [#10491](https://github.com/netbox-community/netbox/issues/10491) - Clarify representation of blocking contact assignments during contact deletion +* [#10513](https://github.com/netbox-community/netbox/issues/10513) - Disable the reassignment of a module to a new device +* [#10517](https://github.com/netbox-community/netbox/issues/10517) - Automatically inherit site assignment from cluster when creating a virtual machine +* [#10559](https://github.com/netbox-community/netbox/issues/10559) - Permit the pinning of a VM to a particular device within a cluster which has no site assignment +* [#10562](https://github.com/netbox-community/netbox/issues/10562) - Correct URL for contacts table tags column --- ## v3.3.4 (2022-09-16) ### Bug Fixes + * [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI * [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 24e5a0ea9..ce3edc07c 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,32 +8,156 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `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 contact model introduced in NetBox v3.1 prior to upgrading. +* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. +* 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, use `custom_field_data` instead. ### 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. + +#### CSV-Based Bulk Updates ([#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. + #### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071)) A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. ### Enhancements +* [#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 +* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data +* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type +* [#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 +* [#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 +* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 ### Plugins API +* [#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) - Introduce `PluginMenu` for top-level plugin navigation menus +* [#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) - Introduce `django_apps` plugin configuration parameter +* [#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 * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 +* [#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 custom `import_object()` function +* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request 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 * 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 a `description` field + * Added optional `weight` and `weight_unit` fields +* dcim.Module + * Added a `description` field +* dcim.ModuleType + * Added a `description` field + * Added optional `weight` and `weight_unit` fields +* dcim.PowerFeed + * Added a `description` field +* dcim.PowerPanel + * Added `description` and `comments` fields +* dcim.Rack + * Added a `description` field + * Added optional `weight` and `weight_unit` fields +* dcim.RackReservation + * Added a `comments` field +* dcim.VirtualChassis + * Added `description` and `comments` fields +* extras.CustomLink + * Renamed `content_type` field to `content_types` +* extras.ExportTemplate + * Renamed `content_type` field to `content_types` +* ipam.Aggregate + * Added a `comments` field +* ipam.ASN + * Added a `comments` field * ipam.FHRPGroup + * Added a `comments` field * Added optional `name` field +* 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 8f6e2930a..011d4414f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,7 +30,7 @@ plugins: - os.chdir('netbox/') - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() - rendering: + options: heading_level: 3 members_order: source show_root_heading: true @@ -38,7 +38,6 @@ plugins: show_root_toc_entry: false show_source: false extra: - readthedocs: !ENV READTHEDOCS social: - icon: fontawesome/brands/github link: https://github.com/netbox-community/netbox @@ -133,6 +132,7 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Exceptions: 'plugins/development/exceptions.md' + - Search: 'plugins/development/search.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' @@ -245,6 +245,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' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4a8e2bd28..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', 'account', - '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/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 12975b5d6..6e9ae516c 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -30,6 +30,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Account number' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -40,7 +44,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): (None, ('asns', 'account', )), ) nullable_fields = ( - 'asns', 'account', 'comments', + 'asns', 'account', 'description', 'comments', ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 77ebb3de9..4976e2d9b 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'account', 'comments', + 'name', 'slug', 'account', 'description', 'comments', 'tags', ) @@ -32,7 +32,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'service_id', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags' ] @@ -41,7 +41,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm): class Meta: model = CircuitType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') help_texts = { 'name': 'Name of circuit type', } @@ -73,5 +73,5 @@ class CircuitCSVForm(NetBoxModelCSVForm): model = Circuit fields = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', - 'description', 'comments', + 'description', 'comments', 'tags' ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 29410ffdf..9ad825299 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', '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', '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', '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 91% rename from netbox/circuits/forms/models.py rename to netbox/circuits/forms/model_forms.py index 17c2e7480..ab1b6bca2 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,14 +29,14 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asns', 'tags')), + ('Provider', ('name', 'slug', 'asns', 'description', 'tags')), ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'account', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags', ] help_texts = { 'name': "Full name of the provider", @@ -64,6 +63,12 @@ class ProviderNetworkForm(NetBoxModelForm): class CircuitTypeForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Circuit Type', ( + 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = CircuitType fields = [ 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/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 ea74eeb40..9d302bb8e 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -7,7 +7,7 @@ from django.urls import reverse 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 +23,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 +73,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( diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index bd63ff0c6..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. @@ -34,9 +33,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -57,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. @@ -75,13 +71,6 @@ 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') diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py new file mode 100644 index 000000000..673f6308f --- /dev/null +++ b/netbox/circuits/search.py @@ -0,0 +1,55 @@ +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), + ('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/circuits.py b/netbox/circuits/tables/circuits.py index f9ab7e190..477f9c1ab 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -1,8 +1,9 @@ import django_tables2 as tables - from circuits.models import * +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin + from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin + from .columns import CommitRateColumn __all__ = ( @@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable): default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') -class CircuitTable(TenancyColumnsMixin, NetBoxTable): +class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable): ) commit_rate = CommitRateColumn() comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='circuits:circuit_list' ) diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index 3e2fd1193..9de8d25b2 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -1,7 +1,8 @@ import django_tables2 as tables -from django_tables2.utils import Accessor - from circuits.models import * +from django_tables2.utils import Accessor +from tenancy.tables import ContactsColumnMixin + from netbox.tables import NetBoxTable, columns __all__ = ( @@ -10,7 +11,7 @@ __all__ = ( ) -class ProviderTable(NetBoxTable): +class ProviderTable(ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable): verbose_name='Circuits' ) comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='circuits:provider_list' ) @@ -41,8 +39,8 @@ class ProviderTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asns', 'account', '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', 'account', 'circuit_count') diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 9644c0b02..54d001c8d 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -50,6 +50,13 @@ 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 = { 'account': '5678', 'comments': 'New comments', @@ -62,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') @@ -84,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', } @@ -107,11 +123,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') @@ -136,6 +154,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, @@ -159,11 +184,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') @@ -182,6 +209,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..dc809666b 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,11 +44,13 @@ 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() @@ -84,6 +89,7 @@ class ProviderNetworkListView(generic.ObjectListView): table = tables.ProviderNetworkTable +@register_model_view(ProviderNetwork) class ProviderNetworkView(generic.ObjectView): queryset = ProviderNetwork.objects.all() @@ -103,11 +109,13 @@ 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() @@ -144,6 +152,7 @@ class CircuitTypeListView(generic.ObjectListView): table = tables.CircuitTypeTable +@register_model_view(CircuitType) class CircuitTypeView(generic.ObjectView): queryset = CircuitType.objects.all() @@ -157,11 +166,13 @@ 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() @@ -202,15 +213,18 @@ 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() @@ -318,11 +332,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/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca3..9317d7c51 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneSerializerField(required=False) + time_zone = TimeZoneSerializerField(required=False, allow_null=True) asns = SerializedPKRelatedField( queryset=ASN.objects.all(), serializer=NestedASNSerializer, @@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer): 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 +209,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', '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 +243,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', ] @@ -315,27 +317,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer): ) 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', ] @@ -652,8 +655,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) @@ -677,8 +680,8 @@ class ModuleSerializer(NetBoxModelSerializer): 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', 'serial', 'asset_tag', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1016,7 +1019,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 +1085,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 +1107,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 +1141,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/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 7d35a40f9..8466d4861 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1314,6 +1314,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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3a66e6c30..78afd816c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -320,7 +320,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', 'weight_unit' ] def search(self, queryset, name, value): @@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): 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): @@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): 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(): @@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter to_field_name='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)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), label='Device type (ID)', @@ -1360,7 +1366,7 @@ class InterfaceFilterSet( try: devices = Device.objects.filter(pk__in=id_list) for device in devices: - vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) + vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() 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_edit.py b/netbox/dcim/forms/bulk_edit.py index 396f7e59b..1e58dd2f7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -127,22 +127,26 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Contact E-mail' ) - description = forms.CharField( - max_length=100, - required=False - ) 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', ) @@ -281,6 +285,24 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + mounting_depth = forms.IntegerField( + required=False, + min_value=1 + ) + 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' @@ -288,12 +310,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', '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', + 'weight_unit', 'description', 'comments', ) @@ -310,14 +336,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): @@ -355,12 +386,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): 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 +421,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): @@ -472,15 +541,23 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False, 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', ) @@ -501,12 +578,20 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False, 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', 'serial', 'description')), ) - nullable_fields = ('serial',) + nullable_fields = ('serial', 'description', 'comments') class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -543,39 +628,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 +698,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,6 +760,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=BulkEditNullBooleanSelect ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -669,10 +771,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): 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') # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f0fd9bf86..2b77ef5a9 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -56,7 +56,7 @@ class RegionCSVForm(NetBoxModelCSVForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class SiteGroupCSVForm(NetBoxModelCSVForm): @@ -100,7 +100,7 @@ class SiteCSVForm(NetBoxModelCSVForm): 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( @@ -137,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -145,7 +145,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm): 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)'), } @@ -196,7 +196,8 @@ class RackCSVForm(NetBoxModelCSVForm): 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', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -240,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm): 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) @@ -263,7 +264,7 @@ class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class DeviceRoleCSVForm(NetBoxModelCSVForm): @@ -271,7 +272,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm): 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)'), } @@ -288,7 +289,7 @@ class PlatformCSVForm(NetBoxModelCSVForm): 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): @@ -387,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): 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): @@ -424,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -451,7 +452,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.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): @@ -502,7 +503,7 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): 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): @@ -525,7 +526,7 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): 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): @@ -542,7 +543,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm): 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' ) @@ -570,13 +571,13 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): 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: @@ -658,7 +659,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): @@ -701,7 +702,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): 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', @@ -711,7 +712,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): 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: @@ -742,7 +743,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): 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' } @@ -756,7 +757,7 @@ class ModuleBayCSVForm(NetBoxModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'position', 'description') + fields = ('device', 'name', 'label', 'position', 'description', 'tags') class DeviceBayCSVForm(NetBoxModelCSVForm): @@ -776,13 +777,13 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): 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: @@ -831,7 +832,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): model = InventoryItem fields = ( 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags' ) def __init__(self, *args, **kwargs): @@ -927,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm): 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)'), @@ -984,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master') + fields = ('name', 'domain', 'master', 'description', 'comments', 'tags') # @@ -1005,7 +1006,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) @@ -1061,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): 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): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index cc5cf362f..537a89bad 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -3,7 +3,7 @@ from django import forms 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): @@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type): label='Power Feed', disabled_indicator='_occupied', query_params={ - 'powerpanel_id': f'$termination_{cable_end}_powerpanel', + 'power_panel_id': f'$termination_{cable_end}_powerpanel', } ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 96b0d1319..905a898df 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -116,7 +116,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +222,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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', 'weight_unit')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte required=False ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class RackElevationFilterForm(RackFilterForm): @@ -298,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('User', ('user_id',)), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -354,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -363,13 +371,14 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +474,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', '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 +546,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 +578,7 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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')), @@ -707,7 +731,7 @@ class DeviceFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -737,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -766,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -876,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), @@ -1135,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'position')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1185,7 +1209,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1195,7 +1219,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', '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 95% rename from netbox/dcim/forms/models.py rename to netbox/dcim/forms/model_forms.py index b33023ece..539c48709 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/model_forms.py @@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm): ) slug = SlugField() + fieldsets = ( + ('Region', ( + 'parent', 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = Region fields = ( @@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm): ) slug = SlugField() + fieldsets = ( + ('Site Group', ( + 'parent', 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = SiteGroup fields = ( @@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class RackRoleForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Rack Role', ( + 'name', 'slug', 'color', 'description', 'tags', + )), + ) + class Meta: model = RackRole fields = [ @@ -260,7 +278,7 @@ 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', 'weight_unit', 'description', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -273,6 +291,7 @@ class RackForm(TenancyForm, NetBoxModelForm): 'type': StaticSelect(), 'width': StaticSelect(), 'outer_unit': StaticSelect(), + 'weight_unit': StaticSelect(), } @@ -323,6 +342,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ), widget=StaticSelect() ) + comments = CommentField() fieldsets = ( ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), @@ -333,13 +353,19 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): model = RackReservation fields = [ 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'tags', + 'description', 'comments', 'tags', ] class ManufacturerForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Manufacturer', ( + 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = Manufacturer fields = [ @@ -358,11 +384,12 @@ class DeviceTypeForm(NetBoxModelForm): fieldsets = ( ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', + 'manufacturer', 'model', 'slug', 'description', 'tags', )), ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', )), + ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), ) @@ -370,7 +397,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(), @@ -380,7 +407,8 @@ class DeviceTypeForm(NetBoxModelForm): }), 'rear_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS - }) + }), + 'weight_unit': StaticSelect(), } @@ -391,21 +419,30 @@ 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() + fieldsets = ( + ('Device Role', ( + 'name', 'slug', 'color', 'vm_role', 'description', 'tags', + )), + ) + class Meta: model = DeviceRole fields = [ @@ -422,6 +459,13 @@ class PlatformForm(NetBoxModelForm): max_length=64 ) + fieldsets = ( + ('Platform', ( + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', + + )), + ) + class Meta: model = Platform fields = [ @@ -547,7 +591,7 @@ 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", @@ -661,7 +705,7 @@ class ModuleForm(NetBoxModelForm): fieldsets = ( ('Module', ( - 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + 'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags', )), ('Hardware', ( 'serial', 'asset_tag', 'replicate_components', 'adopt_components', @@ -672,13 +716,14 @@ class ModuleForm(NetBoxModelForm): model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'adopt_components', 'comments', + 'replicate_components', 'adopt_components', 'description', 'comments', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.pk: + self.fields['device'].disabled = True self.fields['replicate_components'].initial = False self.fields['replicate_components'].disabled = True self.fields['adopt_components'].initial = False @@ -748,11 +793,13 @@ class ModuleForm(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, @@ -795,15 +842,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', ] @@ -849,7 +897,7 @@ class PowerFeedForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Panel', ('region', 'site', 'power_panel', 'description')), ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) @@ -858,7 +906,7 @@ class PowerFeedForm(NetBoxModelForm): model = PowerFeed fields = [ 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', - 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -877,11 +925,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(), @@ -1576,6 +1625,12 @@ class InventoryItemForm(DeviceComponentForm): class InventoryItemRoleForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Inventory Item Role', ( + 'name', 'slug', 'color', 'description', 'tags', + )), + ) + class Meta: model = InventoryItemRole fields = [ diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index a03597db1..afdaa4fcc 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -3,7 +3,7 @@ from django import forms 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', diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 023aba8f1..82ee093dd 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -30,7 +30,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'comments', + 'description', 'comments', ] @@ -42,7 +42,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] # 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/types.py b/netbox/dcim/graphql/types.py index 52a98278a..bb414ed00 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 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/0163_rack_devicetype_moduletype_weights.py b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py new file mode 100644 index 000000000..09bef5736 --- /dev/null +++ b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py @@ -0,0 +1,58 @@ +# Generated by Django 4.0.7 on 2022-09-23 01:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0162_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + 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='moduletype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + 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='rack', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + 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='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + ] 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..5bd087beb --- /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_rack_devicetype_moduletype_weights'), + ] + + 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/models/cables.py b/netbox/dcim/models/cables.py index fad3e8bd6..c51b59f94 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. """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 59d63ef7b..8855107b3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1025,27 +1025,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]) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d0d9001ad..78282f893 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,8 @@ import decimal - import yaml +from functools import cached_property + from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError @@ -17,10 +18,11 @@ 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__ = ( @@ -43,35 +45,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). @@ -134,12 +117,9 @@ 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', ) class Meta: @@ -315,7 +295,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 @@ -335,16 +315,13 @@ class ModuleType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - comments = models.TextField( - blank=True - ) # Generic relations images = GenericRelation( to='extras.ImageAttachment' ) - clone_fields = ('manufacturer',) + clone_fields = ('manufacturer', 'weight', 'weight_unit',) class Meta: ordering = ('manufacturer', 'model') @@ -416,14 +393,6 @@ 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 ) @@ -432,16 +401,6 @@ class DeviceRole(OrganizationalModel): verbose_name='VM 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]) @@ -453,14 +412,6 @@ 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, @@ -481,22 +432,12 @@ class Platform(OrganizationalModel): verbose_name='NAPALM arguments', 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. @@ -640,9 +581,6 @@ class Device(NetBoxModel, ConfigContextModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -946,8 +884,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. @@ -980,9 +930,6 @@ class Module(NetBoxModel, ConfigContextModel): verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - comments = models.TextField( - blank=True - ) clone_fields = ('device', 'module_type') @@ -995,6 +942,14 @@ class Module(NetBoxModel, ConfigContextModel): def get_absolute_url(self): return reverse('dcim:module', args=[self.pk]) + def clean(self): + super().clean() + + if self.module_bay.device != self.device: + raise ValidationError( + f"Module must be installed within a module bay belonging to the assigned device ({self.device})." + ) + def save(self, *args, **kwargs): is_new = self.pk is None @@ -1052,7 +1007,7 @@ class Module(NetBoxModel, ConfigContextModel): # Virtual chassis # -class VirtualChassis(NetBoxModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py new file mode 100644 index 000000000..b5449332b --- /dev/null +++ b/netbox/dcim/models/mixins.py @@ -0,0 +1,45 @@ +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 is not None and not self.weight_unit: + raise ValidationError("Must specify a unit when setting a weight") + elif self.weight is None: + self.weight_unit = '' diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 39f0f37ef..e79cf4c44 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,9 +6,8 @@ from django.db import models from django.urls import reverse 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. """ @@ -77,7 +76,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): +class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -132,9 +131,6 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): default=0, editable=False ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 10550e906..e37fc8dc3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,5 @@ import decimal +from functools import cached_property from django.apps import apps from django.contrib.auth.models import User @@ -13,12 +14,13 @@ from django.urls import reverse 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 PowerPort -from .devices import Device +from .devices import Device, Module +from .mixins import WeightMixin from .power import PowerFeed __all__ = ( @@ -36,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. @@ -165,8 +149,13 @@ class Rack(NetBoxModel): choices=RackDimensionUnitChoices, blank=True, ) - comments = models.TextField( - blank=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 @@ -185,7 +174,7 @@ 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', 'weight_unit', ) class Meta: @@ -454,8 +443,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. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 9ddadace2..c760119fb 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,12 +2,11 @@ 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 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 +27,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', @@ -102,25 +82,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', @@ -170,7 +131,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). @@ -227,10 +188,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 @@ -253,9 +210,6 @@ class Site(NetBoxModel): null=True, help_text='GPS coordinate (longitude)' ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -298,25 +252,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, @@ -329,10 +269,6 @@ class Location(NestedGroupModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Generic relations vlan_groups = GenericRelation( diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py new file mode 100644 index 000000000..d34a78888 --- /dev/null +++ b/netbox/dcim/search.py @@ -0,0 +1,293 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class CableIndex(SearchIndex): + model = models.Cable + fields = ( + ('label', 100), + ) + + +@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), + ('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), + ('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), + ('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), + ('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), + ('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), + ) + + +@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), + ('comments', 5000), + ) + + +@register_search +class RackReservationIndex(SearchIndex): + model = models.RackReservation + fields = ( + ('description', 500), + ) + + +@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) + ) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 3872bc4fe..9a847acc9 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -35,7 +35,7 @@ class Node(Hyperlink): """ def __init__(self, position, width, url, color, labels, radius=10, **extra): - super(Node, self).__init__(href=url, target='_blank', **extra) + super(Node, self).__init__(href=url, target='_parent', **extra) x, y = position diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 573fc966c..6c57e6023 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -9,6 +9,7 @@ from svgwrite.text import Text from django.conf import settings from django.core.exceptions import FieldError from django.db.models import Q +from django.template.defaultfilters import floatformat from django.urls import reverse from django.utils.http import urlencode @@ -41,7 +42,7 @@ def get_device_description(device): device.device_role, device.device_type.manufacturer.name, device.device_type.model, - device.device_type.u_height, + floatformat(device.device_type.u_height), device.asset_tag or '', device.serial or '' ) 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 142c7ef67..45a210080 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,12 +1,10 @@ import django_tables2 as tables +from dcim import models from django_tables2.utils import Accessor +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, - InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, -) from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin + from .template_code import * __all__ = ( @@ -92,7 +90,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', @@ -123,7 +121,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', @@ -137,7 +135,7 @@ class PlatformTable(NetBoxTable): # Devices # -class DeviceTable(TenancyColumnsMixin, NetBoxTable): +class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK @@ -201,20 +199,17 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): verbose_name='VC Priority' ) comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:device_list' ) 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', @@ -241,7 +236,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 @@ -315,7 +310,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', @@ -334,7 +329,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' @@ -357,7 +352,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', @@ -377,7 +372,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', @@ -400,7 +395,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', @@ -421,7 +416,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', @@ -449,7 +444,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', @@ -469,7 +464,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', @@ -533,7 +528,7 @@ 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', @@ -567,7 +562,7 @@ 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', @@ -606,7 +601,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', @@ -629,7 +624,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', @@ -655,7 +650,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', @@ -675,7 +670,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', @@ -716,7 +711,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', @@ -737,7 +732,7 @@ class DeviceDeviceBayTable(DeviceBayTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) @@ -766,7 +761,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', @@ -780,7 +775,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', @@ -810,7 +805,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', @@ -829,7 +824,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', @@ -854,7 +849,7 @@ class InventoryItemRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItemRole + model = models.InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) @@ -877,11 +872,15 @@ 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') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c08..a52d41b70 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,11 +1,9 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, -) +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, DEVICE_WEIGHT __all__ = ( 'ConsolePortTemplateTable', @@ -27,7 +25,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(NetBoxTable): +class ManufacturerTable(ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -43,15 +41,12 @@ class ManufacturerTable(NetBoxTable): verbose_name='Platforms' ) slug = tables.Column() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) class Meta(NetBoxTable.Meta): - model = Manufacturer + model = models.Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'contacts', 'actions', 'created', 'last_updated', @@ -85,12 +80,19 @@ class DeviceTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:devicetype_list' ) + u_height = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) + weight = columns.TemplateColumn( + template_code=DEVICE_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', @@ -120,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" @@ -132,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" @@ -144,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" @@ -156,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" @@ -171,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" @@ -187,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" @@ -200,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" @@ -211,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" @@ -222,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = DeviceBayTemplate + model = models.DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" @@ -242,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..9df26eb73 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 DEVICE_WEIGHT __all__ = ( 'ModuleTable', @@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + weight = columns.TemplateColumn( + template_code=DEVICE_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', @@ -59,8 +64,8 @@ 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', 'serial', 'asset_tag', 'description', + 'comments', 'tags', ) default_columns = ( 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 6696d516a..feff29e12 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,9 @@ import django_tables2 as tables - from dcim.models import PowerFeed, PowerPanel +from tenancy.tables import ContactsColumnMixin + from netbox.tables import NetBoxTable, columns + from .devices import CableTerminationTable __all__ = ( @@ -14,7 +16,7 @@ __all__ = ( # Power panels # -class PowerPanelTable(NetBoxTable): +class PowerPanelTable(ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -29,9 +31,7 @@ class PowerPanelTable(NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -39,7 +39,8 @@ class PowerPanelTable(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') @@ -78,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 39553bac0..b360002d2 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -3,7 +3,8 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin +from .template_code import DEVICE_WEIGHT __all__ = ( 'RackTable', @@ -37,7 +38,7 @@ class RackRoleTable(NetBoxTable): # Racks # -class RackTable(TenancyColumnsMixin, NetBoxTable): +class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -68,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Power' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:rack_list' ) @@ -82,13 +80,18 @@ class RackTable(TenancyColumnsMixin, NetBoxTable): template_code="{{ record.outer_depth }} {{ record.outer_unit }}", verbose_name='Outer Depth' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_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', + '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', @@ -120,6 +123,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Units' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) @@ -127,7 +131,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 5dc2aa611..f013025f7 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,8 +1,9 @@ import django_tables2 as tables - from dcim.models import Location, Region, Site, SiteGroup +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin + from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin + from .template_code import LOCATION_BUTTONS __all__ = ( @@ -17,7 +18,7 @@ __all__ = ( # Regions # -class RegionTable(NetBoxTable): +class RegionTable(ContactsColumnMixin, NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -26,9 +27,6 @@ class RegionTable(NetBoxTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:region_list' ) @@ -46,7 +44,7 @@ class RegionTable(NetBoxTable): # Site groups # -class SiteGroupTable(NetBoxTable): +class SiteGroupTable(ContactsColumnMixin, NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) @@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable): # Sites # -class SiteTable(TenancyColumnsMixin, NetBoxTable): +class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable): verbose_name='ASN Count' ) comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:site_list' ) @@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable): # Locations # -class LocationTable(TenancyColumnsMixin, NetBoxTable): +class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='dcim:location_list' ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index dfc77b854..9b8fb8fd6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -15,6 +15,11 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ +DEVICE_WEIGHT = """ +{% load helpers %} +{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %} +""" + DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7a745721b..92298bd73 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, 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, 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, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) Rack.objects.bulk_create(racks) @@ -517,6 +517,14 @@ 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_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 +696,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 +847,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 +871,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 +959,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() @@ -1646,6 +1670,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_type': [device_types[0].slug, device_types[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_devicerole(self): device_roles = DeviceRole.objects.all()[:2] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 50b36e36d..d563dcfd6 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,7 +16,6 @@ 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.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -50,6 +53,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 +97,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 +156,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 +173,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 +226,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 +244,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 +268,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 +298,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 +323,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 +362,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') @@ -351,6 +399,13 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 2,Location 2,Rack 6,active,19,42", ) + 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 = { 'site': sites[1].pk, 'location': locations[1].pk, @@ -383,11 +438,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 +461,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 +1507,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 +1532,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 +1553,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 +1579,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 +1633,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 +1675,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, @@ -1778,10 +1865,12 @@ class ModuleTestCase( ModuleBay(device=devices[0], name='Module Bay 2'), ModuleBay(device=devices[0], name='Module Bay 3'), ModuleBay(device=devices[0], name='Module Bay 4'), + ModuleBay(device=devices[0], name='Module Bay 5'), ModuleBay(device=devices[1], name='Module Bay 1'), ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 3'), ModuleBay(device=devices[1], name='Module Bay 4'), + ModuleBay(device=devices[1], name='Module Bay 5'), ) ModuleBay.objects.bulk_create(module_bays) @@ -1795,7 +1884,7 @@ class ModuleTestCase( tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'device': devices[1].pk, + 'device': devices[0].pk, 'module_bay': module_bays[3].pk, 'module_type': module_types[0].pk, 'serial': 'A', @@ -1813,6 +1902,13 @@ class ModuleTestCase( "Device 2,Module Bay 3,Module Type 3,C,C", ) + cls.csv_update_data = ( + "id,serial", + f"{modules[0].pk},Serial 2", + f"{modules[1].pk},Serial 3", + f"{modules[2].pk},Serial 1", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_module_component_replication(self): self.add_permissions('dcim.add_module') @@ -1867,7 +1963,6 @@ class ModuleTestCase( self.assertIsNone(interface.module) # Create a module with adopted components - form_data['module_bay'] = ModuleBay.objects.filter(device=device).first() form_data['module_type'] = module_type form_data['replicate_components'] = False form_data['adopt_components'] = True @@ -1893,11 +1988,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') @@ -1931,6 +2027,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() @@ -1952,11 +2055,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') @@ -1988,6 +2092,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() @@ -2009,11 +2120,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') @@ -2051,6 +2163,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() @@ -2078,11 +2197,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') @@ -2120,6 +2240,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() @@ -2246,6 +2373,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] @@ -2273,11 +2407,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') @@ -2312,6 +2447,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() @@ -2333,11 +2475,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') @@ -2371,6 +2514,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() @@ -2392,11 +2542,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') @@ -2425,6 +2576,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 @@ -2437,11 +2595,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') @@ -2470,6 +2629,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 @@ -2486,9 +2652,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') @@ -2532,6 +2698,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 @@ -2539,11 +2712,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') @@ -2562,6 +2736,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', @@ -2614,9 +2795,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') @@ -2642,6 +2826,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, @@ -2725,6 +2916,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', } @@ -2749,11 +2947,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') @@ -2771,6 +2970,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, @@ -2797,11 +3003,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') @@ -2827,6 +3034,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, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c11a92a99..ecd2d46c5 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,7 @@ 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'))), # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), @@ -276,11 +189,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 +199,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 +210,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 +221,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 +232,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 +243,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 +254,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 +265,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 +275,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 +285,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 +295,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 +312,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 +325,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 +334,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 +343,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 84a26e3b0..a16a2b9fc 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,11 +235,13 @@ 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() @@ -242,6 +294,7 @@ class SiteGroupListView(generic.ObjectListView): table = tables.SiteGroupTable +@register_model_view(SiteGroup) class SiteGroupView(generic.ObjectView): queryset = SiteGroup.objects.all() @@ -270,11 +323,13 @@ 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() @@ -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,11 +429,13 @@ 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() @@ -424,6 +482,7 @@ class LocationListView(generic.ObjectListView): table = tables.LocationTable +@register_model_view(Location) class LocationView(generic.ObjectView): queryset = Location.objects.all() @@ -463,11 +522,13 @@ 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() @@ -516,6 +577,7 @@ class RackRoleListView(generic.ObjectListView): table = tables.RackRoleTable +@register_model_view(RackRole) class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() @@ -534,11 +596,13 @@ 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() @@ -629,6 +693,7 @@ class RackElevationListView(generic.ObjectListView): }) +@register_model_view(Rack) class RackView(generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') @@ -672,12 +737,14 @@ 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() @@ -712,10 +779,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 @@ -728,6 +797,7 @@ class RackReservationEditView(generic.ObjectEditView): return obj +@register_model_view(RackReservation, 'delete') class RackReservationDeleteView(generic.ObjectDeleteView): queryset = RackReservation.objects.all() @@ -776,6 +846,7 @@ class ManufacturerListView(generic.ObjectListView): table = tables.ManufacturerTable +@register_model_view(Manufacturer) class ManufacturerView(generic.ObjectView): queryset = Manufacturer.objects.all() @@ -802,11 +873,13 @@ 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() @@ -846,6 +919,7 @@ class DeviceTypeListView(generic.ObjectListView): table = tables.DeviceTypeTable +@register_model_view(DeviceType) class DeviceTypeView(generic.ObjectView): queryset = DeviceType.objects.all() @@ -857,85 +931,157 @@ 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() +@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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + hide_if_empty=True + ) + + class DeviceTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_devicetype', @@ -1001,6 +1147,7 @@ class ModuleTypeListView(generic.ObjectListView): table = tables.ModuleTypeTable +@register_model_view(ModuleType) class ModuleTypeView(generic.ObjectView): queryset = ModuleType.objects.all() @@ -1012,64 +1159,115 @@ 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() +@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', + 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', + 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', + 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', + 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', + 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', + 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', + hide_if_empty=True + ) + + class ModuleTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_moduletype', @@ -1126,11 +1324,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() @@ -1160,11 +1360,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() @@ -1194,11 +1396,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() @@ -1228,11 +1432,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() @@ -1262,11 +1468,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() @@ -1296,11 +1504,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() @@ -1330,11 +1540,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() @@ -1364,11 +1576,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() @@ -1398,11 +1612,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() @@ -1443,11 +1659,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() @@ -1481,6 +1699,7 @@ class DeviceRoleListView(generic.ObjectListView): table = tables.DeviceRoleTable +@register_model_view(DeviceRole) class DeviceRoleView(generic.ObjectView): queryset = DeviceRole.objects.all() @@ -1498,11 +1717,13 @@ 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() @@ -1545,6 +1766,7 @@ class PlatformListView(generic.ObjectListView): filterset_form = forms.PlatformFilterForm +@register_model_view(Platform) class PlatformView(generic.ObjectView): queryset = Platform.objects.all() @@ -1561,11 +1783,13 @@ 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() @@ -1600,6 +1824,7 @@ class DeviceListView(generic.ObjectListView): template_name = 'dcim/device_list.html' +@register_model_view(Device) class DeviceView(generic.ObjectView): queryset = Device.objects.all() @@ -1618,42 +1843,90 @@ class DeviceView(generic.ObjectView): return { 'services': services, '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', + 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', + 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', + 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', + 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', + hide_if_empty=True + ) def get_children(self, request, parent): return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related( @@ -1662,94 +1935,84 @@ 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', + 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', + 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', + 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', + 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', + 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' + ) class DeviceBulkImportView(generic.BulkImportView): @@ -1797,7 +2060,68 @@ 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.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', + ) + + +@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', + ) + + 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', + ) + + +# +# Modules # class ModuleListView(generic.ObjectListView): @@ -1807,15 +2131,18 @@ 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() @@ -1851,6 +2178,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,11 +2189,13 @@ 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() @@ -1897,6 +2227,10 @@ class ConsolePortBulkDeleteView(generic.BulkDeleteView): table = tables.ConsolePortTable +# Trace view +register_model_view(ConsolePort, 'trace', kwargs={'model': ConsolePort})(PathTraceView) + + # # Console server ports # @@ -1909,6 +2243,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,11 +2254,13 @@ 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() @@ -1955,6 +2292,10 @@ class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView): table = tables.ConsoleServerPortTable +# Trace view +register_model_view(ConsoleServerPort, 'trace', kwargs={'model': ConsoleServerPort})(PathTraceView) + + # # Power ports # @@ -1967,6 +2308,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,11 +2319,13 @@ 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() @@ -2013,6 +2357,10 @@ class PowerPortBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPortTable +# Trace view +register_model_view(PowerPort, 'trace', kwargs={'model': PowerPort})(PathTraceView) + + # # Power outlets # @@ -2025,6 +2373,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,11 +2384,13 @@ 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() @@ -2071,6 +2422,10 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView): table = tables.PowerOutletTable +# Trace view +register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView) + + # # Interfaces # @@ -2083,6 +2438,7 @@ class InterfaceListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(Interface) class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() @@ -2137,11 +2493,13 @@ 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() @@ -2173,6 +2531,10 @@ class InterfaceBulkDeleteView(generic.BulkDeleteView): table = tables.InterfaceTable +# Trace view +register_model_view(Interface, 'trace', kwargs={'model': Interface})(PathTraceView) + + # # Front ports # @@ -2185,6 +2547,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,11 +2558,13 @@ 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() @@ -2231,6 +2596,10 @@ class FrontPortBulkDeleteView(generic.BulkDeleteView): table = tables.FrontPortTable +# Trace view +register_model_view(FrontPort, 'trace', kwargs={'model': FrontPort})(PathTraceView) + + # # Rear ports # @@ -2243,6 +2612,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,11 +2623,13 @@ 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() @@ -2289,6 +2661,10 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTable +# Trace view +register_model_view(RearPort, 'trace', kwargs={'model': RearPort})(PathTraceView) + + # # Module bays # @@ -2301,6 +2677,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,11 +2688,13 @@ 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() @@ -2355,6 +2734,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 +2745,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 +2790,7 @@ class DeviceBayPopulateView(generic.ObjectEditView): }) +@register_model_view(DeviceBay, 'depopulate') class DeviceBayDepopulateView(generic.ObjectEditView): queryset = DeviceBay.objects.all() @@ -2476,10 +2860,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,6 +2888,7 @@ class InventoryItemCreateView(generic.ComponentCreateView): return instance +@register_model_view(InventoryItem, 'delete') class InventoryItemDeleteView(generic.ObjectDeleteView): queryset = InventoryItem.objects.all() @@ -2542,6 +2929,7 @@ class InventoryItemRoleListView(generic.ObjectListView): table = tables.InventoryItemRoleTable +@register_model_view(InventoryItemRole) class InventoryItemRoleView(generic.ObjectView): queryset = InventoryItemRole.objects.all() @@ -2551,11 +2939,13 @@ 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() @@ -2711,65 +3101,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,6 +3140,7 @@ class CableEditView(generic.ObjectEditView): return obj +@register_model_view(Cable, 'delete') class CableDeleteView(generic.ObjectDeleteView): queryset = Cable.objects.all() @@ -2891,6 +3229,7 @@ class VirtualChassisListView(generic.ObjectListView): filterset_form = forms.VirtualChassisFilterForm +@register_model_view(VirtualChassis) class VirtualChassisView(generic.ObjectView): queryset = VirtualChassis.objects.all() @@ -2908,6 +3247,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 +3316,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() @@ -3118,6 +3460,7 @@ class PowerPanelListView(generic.ObjectListView): table = tables.PowerPanelTable +@register_model_view(PowerPanel) class PowerPanelView(generic.ObjectView): queryset = PowerPanel.objects.all() @@ -3136,11 +3479,13 @@ 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() @@ -3177,15 +3522,18 @@ 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() @@ -3211,3 +3559,7 @@ 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) 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..dce062b84 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'] + + 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..1afb8fa8f 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', 'description', 'user', 'weight', + 'enabled', 'shared', 'parameters', 'created', 'last_updated', + ] + + # # Tags # @@ -360,7 +385,7 @@ class JobResultSerializer(BaseModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + 'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', ] @@ -386,6 +411,10 @@ class ReportDetailSerializer(ReportSerializer): result = JobResultSerializer() +class ReportInputSerializer(serializers.Serializer): + schedule_at = serializers.DateTimeField(required=False, allow_null=True) + + # # Scripts # @@ -417,6 +446,7 @@ class ScriptDetailSerializer(ScriptSerializer): class ScriptInputSerializer(serializers.Serializer): data = serializers.JSONField() commit = serializers.BooleanField() + schedule_at = serializers.DateTimeField(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..ab111b0ec 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.http import Http404 from django_rq.queues import get_connection from rest_framework import status @@ -98,6 +99,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 +243,26 @@ 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(): + schedule_at = input_serializer.validated_data.get('schedule_at') - return Response(serializer.data) + 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, + schedule_at=schedule_at, + ) + report.result = job_result + + serializer = serializers.ReportDetailSerializer(report, context={'request': request}) + + return Response(serializer.data) + return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) # @@ -312,6 +331,7 @@ class ScriptViewSet(ViewSet): if input_serializer.is_valid(): data = input_serializer.data['data'] commit = input_serializer.data['commit'] + schedule_at = input_serializer.validated_data.get('schedule_at') script_content_type = ContentType.objects.get(app_label='extras', model='script') job_result = JobResult.enqueue_job( @@ -323,6 +343,7 @@ class ScriptViewSet(ViewSet): request=copy_safe_request(request), commit=commit, job_timeout=script.job_timeout, + schedule_at=schedule_at, ) 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..ee806f094 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,6 +141,7 @@ class LogLevelChoices(ChoiceSet): class JobResultStatusChoices(ChoiceSet): STATUS_PENDING = 'pending' + STATUS_SCHEDULED = 'scheduled' STATUS_RUNNING = 'running' STATUS_COMPLETED = 'completed' STATUS_ERRORED = 'errored' @@ -146,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet): CHOICES = ( (STATUS_PENDING, 'Pending'), + (STATUS_SCHEDULED, 'Scheduled'), (STATUS_RUNNING, 'Running'), (STATUS_COMPLETED, 'Completed'), (STATUS_ERRORED, 'Errored'), diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index df0af3541..6010c733a 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -16,12 +16,14 @@ __all__ = ( 'ConfigContextFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', + 'JobResultFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'SavedFilterFilterSet', 'TagFilterSet', 'WebhookFilterSet', ) @@ -72,8 +74,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): @@ -92,11 +94,15 @@ class CustomLinkFilterSet(BaseFilterSet): method='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): @@ -115,10 +121,14 @@ class ExportTemplateFilterSet(BaseFilterSet): method='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,6 +139,55 @@ 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', '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', @@ -435,7 +494,32 @@ class JobResultFilterSet(BaseFilterSet): 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' + ) 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' + ) + scheduled_time = django_filters.DateTimeFilter() + scheduled_time__before = django_filters.DateTimeFilter( + field_name='scheduled_time', + lookup_expr='lte' + ) + scheduled_time__after = django_filters.DateTimeFilter( + field_name='scheduled_time', + lookup_expr='gte' + ) status = django_filters.MultipleChoiceFilter( choices=JobResultStatusChoices, null_value=None @@ -444,14 +528,15 @@ class JobResultFilterSet(BaseFilterSet): class Meta: model = JobResult fields = [ - 'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name' + 'id', 'created', 'completed', 'scheduled_time', '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) ) 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..a061d9784 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,11 +1,9 @@ from django import forms -from django.contrib.contenttypes.models import ContentType 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 +12,7 @@ __all__ = ( 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -53,11 +52,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 +75,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 +95,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(), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index e83cac3b9..0f5974698 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -12,6 +12,7 @@ __all__ = ( 'CustomFieldCSVForm', 'CustomLinkCSVForm', 'ExportTemplateCSVForm', + 'SavedFilterCSVForm', 'TagCSVForm', 'WebhookCSVForm', ) @@ -46,38 +47,51 @@ class CustomFieldCSVForm(CSVModelForm): 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( + 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( + 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 SavedFilterCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + help_text="One or more assigned object types" + ) + + class Meta: + model = SavedFilter + fields = ( + 'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 526d47013..479367ff0 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -15,23 +15,26 @@ 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')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( @@ -65,12 +68,64 @@ class CustomFieldFilterForm(FilterForm): ) -class CustomLinkFilterForm(FilterForm): +class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), - ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), + (None, ('q', 'filter')), + ('Attributes', ('obj_type', 'status')), + ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', + 'scheduled_time__before', 'scheduled_time__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() + ) + completed__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + completed__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + scheduled_time__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + scheduled_time__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')), + ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), + ) + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), required=False @@ -92,12 +147,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')), + ('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 +172,36 @@ class ExportTemplateFilterForm(FilterForm): ) -class WebhookFilterForm(FilterForm): +class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), + ('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')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) @@ -160,7 +242,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 +251,9 @@ class TagFilterForm(FilterForm): ) -class ConfigContextFilterForm(FilterForm): +class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'tag_id')), + (None, ('q', 'filter', '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 +340,7 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -296,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ObjectChangeFilterForm(FilterForm): +class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('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 80% rename from netbox/extras/forms/customfields.py rename to netbox/extras/forms/mixins.py index 7574f4f2b..2b64d1a74 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/mixins.py @@ -1,10 +1,13 @@ from django.contrib.contenttypes.models import ContentType +from django import forms from extras.models import * from extras.choices import CustomFieldVisibilityChoices +from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', + 'SavedFiltersMixin', ) @@ -34,7 +37,9 @@ class CustomFieldsMixin: return ContentType.objects.get_for_model(self.model) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type) + return CustomField.objects.filter(content_types=content_type).exclude( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ) def _get_form_field(self, customfield): return customfield.to_form_field() @@ -50,15 +55,19 @@ class CustomFieldsMixin: field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) - if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: - self.fields[field_name].disabled = True - if self.fields[field_name].help_text: - self.fields[field_name].help_text += '
' - self.fields[field_name].help_text += ' ' \ - 'Field is set to read-only.' - # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield 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 = 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 84% rename from netbox/extras/forms/models.py rename to netbox/extras/forms/model_forms.py index bea1fbcc1..97e80100a 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -20,6 +21,7 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'SavedFilterForm', 'TagForm', 'WebhookForm', ) @@ -41,9 +43,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): 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')), ) @@ -63,13 +65,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')), ) @@ -89,13 +91,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): 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 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): } +class SavedFilterForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + + fieldsets = ( + ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Parameters', ('parameters',)), + ) + + class Meta: + model = SavedFilter + exclude = ('user',) + widgets = { + 'parameters': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + 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..aa4f6223b --- /dev/null +++ b/netbox/extras/forms/reports.py @@ -0,0 +1,16 @@ +from django import forms + +from utilities.forms import BootstrapMixin, DateTimePicker + +__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", + ) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 380b4364c..de55a3ee6 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,6 +1,6 @@ from django import forms -from utilities.forms import BootstrapMixin +from utilities.forms import BootstrapMixin, DateTimePicker __all__ = ( 'ScriptForm', @@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form): 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", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Move _commit to the end of the form + # Move _commit and _schedule_at to the end of the form + schedule_at = self.fields.pop('_schedule_at') commit = self.fields.pop('_commit') + self.fields['_schedule_at'] = schedule_at self.fields['_commit'] = commit @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 _commit and _schedule_at fields). """ - return bool(len(self.fields) > 1) + return bool(len(self.fields) > 2) 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/0079_jobresult_scheduled_time.py b/netbox/extras/migrations/0079_jobresult_scheduled_time.py new file mode 100644 index 000000000..c9646f13c --- /dev/null +++ b/netbox/extras/migrations/0079_jobresult_scheduled_time.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0078_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='jobresult', + name='scheduled_time', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterModelOptions( + name='jobresult', + options={'ordering': ['-created']}, + ), + ] diff --git a/netbox/extras/migrations/0080_search.py b/netbox/extras/migrations/0080_search.py new file mode 100644 index 000000000..7a133e84b --- /dev/null +++ b/netbox/extras/migrations/0080_search.py @@ -0,0 +1,35 @@ +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0079_jobresult_scheduled_time'), + ] + + 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(db_index=True)), + ('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'), + }, + ), + ] diff --git a/netbox/extras/migrations/0081_customlink_content_types.py b/netbox/extras/migrations/0081_customlink_content_types.py new file mode 100644 index 000000000..2f0f23509 --- /dev/null +++ b/netbox/extras/migrations/0081_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', '0080_search'), + ] + + 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/0082_exporttemplate_content_types.py b/netbox/extras/migrations/0082_exporttemplate_content_types.py new file mode 100644 index 000000000..34a9c77e6 --- /dev/null +++ b/netbox/extras/migrations/0082_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', '0081_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/0083_savedfilter.py b/netbox/extras/migrations/0083_savedfilter.py new file mode 100644 index 000000000..6bae7ccde --- /dev/null +++ b/netbox/extras/migrations/0083_savedfilter.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.1 on 2022-10-27 18:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0082_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)), + ('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/models/__init__.py b/netbox/extras/models/__init__.py index 3cb6372be..6d2bf288c 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -2,9 +2,11 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField from .models import * +from .search import * from .tags import Tag, TaggedItem __all__ = ( + 'CachedValue', 'ConfigContext', 'ConfigContextModel', 'ConfigRevision', @@ -16,6 +18,7 @@ __all__ = ( 'JournalEntry', 'ObjectChange', 'Report', + 'SavedFilter', 'Script', 'Tag', 'TaggedItem', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..2de806ca6 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,5 +1,6 @@ import re from datetime import datetime, date +import decimal import django_filters from django import forms @@ -15,6 +16,7 @@ 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 +31,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 @@ -93,6 +104,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge 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, @@ -108,6 +124,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge ) weight = models.PositiveSmallIntegerField( default=100, + verbose_name='Display weight', help_text='Fields with higher weights appear lower in a form.' ) validation_minimum = models.IntegerField( @@ -147,8 +164,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge 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: @@ -166,6 +184,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge # 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 +241,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 = ( @@ -297,12 +316,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge return model.objects.filter(pk__in=value) return value - def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): + def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. + enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None @@ -317,6 +337,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 = ( @@ -398,6 +429,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge if self.description: field.help_text = escape(self.description) + # Annotate read-only fields + if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + field.disabled = True + prepend = '
' if field.help_text else '' + field.help_text += f'{prepend} Field is set to read-only.' + return field def to_filter(self, lookup_expr=None): @@ -426,6 +463,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 @@ -475,7 +516,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: @@ -483,12 +524,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') @@ -496,14 +548,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 266953f61..4b4e7c0cf 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import 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 @@ -34,6 +34,7 @@ __all__ = ( 'JobResult', 'JournalEntry', 'Report', + 'SavedFilter', 'Script', 'Webhook', ) @@ -197,10 +198,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, @@ -236,7 +237,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged ) clone_fields = ( - 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: @@ -268,10 +269,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 @@ -301,16 +302,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) class Meta: - ordering = ['content_type', 'name'] - constraints = ( - models.UniqueConstraint( - fields=('content_type', 'name'), - name='%(app_label)s_%(class)s_unique_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]) @@ -356,6 +351,69 @@ 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 + ) + 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]) + + 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. @@ -471,6 +529,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin def get_absolute_url(self): return reverse('extras:journalentry', args=[self.pk]) + def clean(self): + super().clean() + + # Prevent the creation of journal entries on unsupported models + permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) + if self.assigned_object_type not in permitted_types: + raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).") + def get_kind_color(self): return JournalEntryKindChoices.colors.get(self.kind) @@ -497,6 +563,10 @@ class JobResult(models.Model): null=True, blank=True ) + scheduled_time = models.DateTimeField( + null=True, + blank=True + ) user = models.ForeignKey( to=User, on_delete=models.SET_NULL, @@ -517,12 +587,26 @@ 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]) + @property def duration(self): if not self.completed: @@ -543,7 +627,7 @@ 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, *args, **kwargs): """ Create a JobResult instance and enqueue a job using the given callable @@ -551,10 +635,11 @@ class JobResult(models.Model): 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 args: additional args passed to the callable kwargs: additional kargs passed to the callable """ - job_result = cls.objects.create( + job_result: JobResult = cls.objects.create( name=name, obj_type=obj_type, user=user, @@ -562,7 +647,15 @@ class JobResult(models.Model): ) queue = django_rq.get_queue("default") - queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + + if schedule_at: + job_result.status = JobResultStatusChoices.STATUS_SCHEDULED + job_result.scheduled_time = schedule_at + job_result.save() + + 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..b7bb104e7 --- /dev/null +++ b/netbox/extras/models/search.py @@ -0,0 +1,50 @@ +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( + db_index=True + ) + 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/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ef1106aea..7694a1fbe 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,16 +1,16 @@ 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.plugins.utils import import_object -from extras.registry import registry -from netbox.navigation import MenuGroup -from utilities.choices import ButtonColorChoices +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'] = { @@ -55,8 +55,12 @@ 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' @@ -66,26 +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 or menu items (if defined) - if menu := import_object(f"{self.__module__}.{self.menu}"): + # 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) - if menu_items := import_object(f"{self.__module__}.{self.menu_items}"): + 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): @@ -120,185 +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 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 - - -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(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 - - -# -# 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..193be6cfb --- /dev/null +++ b/netbox/extras/plugins/navigation.py @@ -0,0 +1,66 @@ +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 + + +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..5f3d038c6 --- /dev/null +++ b/netbox/extras/plugins/templates.py @@ -0,0 +1,65 @@ +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 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..525608c86 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs): try: report.run(job_result) except Exception as e: - print(e) job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.save() logging.error(f"Error during execution of report {job_result.name}") 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 1df5c9487..da4241e69 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,20 +8,18 @@ from .template_code import * __all__ = ( 'ConfigContextTable', 'CustomFieldTable', + 'JobResultTable', 'CustomLinkTable', 'ExportTemplateTable', 'JournalEntryTable', 'ObjectChangeTable', + 'SavedFilterTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -# -# Custom fields -# - class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True @@ -33,58 +31,82 @@ 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 + ) + + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = JobResult + fields = ( + 'pk', 'id', 'name', 'obj_type', 'job_id', 'created', 'completed', 'scheduled_time', 'user', 'status', + ) + default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', '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', '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 +139,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..b2f4ec0a7 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() 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/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..045391ea8 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,75 @@ 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', 'url'] + create_data = [ + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 4', + 'weight': 100, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['active']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 5', + 'weight': 200, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['planned']}, + }, + { + 'content_types': ['dcim.site'], + 'name': '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', + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='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 +262,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 +283,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): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index d2964106f..2f3c7932a 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse @@ -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]) @@ -373,7 +403,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 +455,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 +472,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 +506,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, @@ -497,7 +531,8 @@ 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']) @@ -531,7 +566,8 @@ 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['url_field'], cf_defaults['url_field']) @@ -548,7 +584,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 +605,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,7 +628,8 @@ 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['url_field'], data_cf['url_field']) @@ -607,7 +646,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,7 +692,8 @@ 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['url_field'], cf_defaults['url_field']) @@ -669,7 +710,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 +728,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,7 +769,8 @@ 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['url_field'], custom_field_data['url_field']) @@ -743,7 +787,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']) @@ -763,7 +808,7 @@ class CustomFieldAPITest(APITestCase): data = { 'custom_fields': { 'text_field': 'ABCD', - 'number_field': 1234, + 'integer_field': 1234, }, } url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) @@ -775,8 +820,9 @@ 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']) @@ -792,8 +838,9 @@ class CustomFieldAPITest(APITestCase): # 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['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_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']) @@ -808,20 +855,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) @@ -860,6 +907,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), @@ -880,10 +928,10 @@ 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) @@ -893,10 +941,11 @@ class CustomFieldImportTest(TestCase): # 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') @@ -906,10 +955,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') @@ -972,7 +1022,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 @@ -987,12 +1037,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): @@ -1006,11 +1056,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() @@ -1034,53 +1084,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) ) @@ -1089,7 +1164,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) ) @@ -1099,42 +1174,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], }), ]) @@ -1146,60 +1224,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_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9f9483bbb..140f05906 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,117 @@ 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', + user=users[0], + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + user=users[1], + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='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_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_plugins.py b/netbox/extras/tests/test_plugins.py index e0ff67a2b..e20dccbd9 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,10 +5,10 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu -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") @@ -59,6 +59,17 @@ 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. @@ -162,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..175ffb9ca 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,95 @@ 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', user=users[0], weight=100, parameters={'status': ['active']}), + SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}), + SavedFilter(name='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', + 'content_types': [site_ct.pk], + 'description': 'Foo', + 'weight': 1000, + 'enabled': True, + 'shared': True, + 'parameters': '{"foo": 123}', + } + + cls.csv_data = ( + 'name,content_types,weight,enabled,shared,parameters', + 'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}', + 'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}', + '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 +238,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 +260,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 +281,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 +420,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/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..4a1350bde 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,15 +31,18 @@ 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() @@ -74,15 +77,18 @@ 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() @@ -117,15 +123,18 @@ 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() @@ -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.SavedFilterCSVForm + 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,15 +237,18 @@ 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() @@ -205,6 +285,7 @@ class TagListView(generic.ObjectListView): table = tables.TagTable +@register_model_view(Tag) class TagView(generic.ObjectView): queryset = Tag.objects.all() @@ -230,11 +311,13 @@ 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() @@ -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,36 @@ 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, - }) + schedule_at = None + 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(): + schedule_at = form.cleaned_data.get("schedule_at") - 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. + 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, + schedule_at=schedule_at, + ) + + 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): @@ -714,6 +817,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): elif form.is_valid(): commit = form.cleaned_data.pop('_commit') + schedule_at = form.cleaned_data.pop("_schedule_at") script_content_type = ContentType.objects.get(app_label='extras', model='script') @@ -726,6 +830,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): request=copy_safe_request(request), commit=commit, job_timeout=script.job_timeout, + schedule_at=schedule_at, ) return redirect('extras:script_result', job_result_pk=job_result.pk) @@ -765,3 +870,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..a93be7934 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -5,11 +5,11 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django_rq import get_queue +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): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8a8fc03d6..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', 'name', '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', ] @@ -175,6 +177,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): queryset=ContentType.objects.filter( model__in=VLANGROUP_SCOPE_TYPES ), + allow_null=True, required=False, default=None ) @@ -214,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', ] @@ -272,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'] @@ -341,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'] @@ -370,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) @@ -414,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', ] @@ -435,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', ] # @@ -464,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/api/views.py b/netbox/ipam/api/views.py index 9db3d7953..9ea38758d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet): serializer_class = serializers.IPAddressSerializer filterset_class = filtersets.IPAddressFilterSet + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + class FHRPGroupViewSet(NetBoxModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') 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/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_edit.py b/netbox/ipam/forms/bulk_edit.py index 67bcf83fb..ed1d1d9e9 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -8,8 +8,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__ = ( @@ -43,15 +43,19 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): 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 +67,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): @@ -103,15 +111,19 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): 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): @@ -128,15 +140,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): @@ -206,9 +222,13 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): 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 +237,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', ) @@ -241,16 +261,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', ) @@ -285,9 +309,13 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): 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 +323,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', ) @@ -329,13 +357,17 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = FHRPGroup fieldsets = ( (None, ('protocol', 'group_id', 'name', 'description')), ('Authentication', ('auth_type', 'auth_key')), ) - nullable_fields = ('auth_type', 'auth_key', 'name', 'description') + nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -405,9 +437,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 = ( @@ -415,7 +451,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ('Site & Group', ('region', 'site_group', 'site', 'group')), ) nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', + 'site', 'group', 'tenant', 'role', 'description', 'comments', ) @@ -433,15 +469,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): @@ -459,15 +499,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 6a9dd91ac..4cd0bb69f 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm): class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') class RouteTargetCSVForm(NetBoxModelCSVForm): @@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): class Meta: model = RouteTarget - fields = ('name', 'description', 'tenant') + fields = ('name', 'tenant', 'description', 'comments', 'tags') class RIRCSVForm(NetBoxModelCSVForm): @@ -62,7 +62,7 @@ class RIRCSVForm(NetBoxModelCSVForm): class Meta: model = RIR - fields = ('name', 'slug', 'is_private', 'description') + fields = ('name', 'slug', 'is_private', 'description', 'tags') help_texts = { 'name': 'RIR name', } @@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') class ASNCSVForm(NetBoxModelCSVForm): @@ -101,8 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description') - help_texts = {} + fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags') class RoleCSVForm(NetBoxModelCSVForm): @@ -110,7 +109,7 @@ class RoleCSVForm(NetBoxModelCSVForm): class Meta: model = Role - fields = ('name', 'slug', 'weight', 'description') + fields = ('name', 'slug', 'weight', 'description', 'tags') class PrefixCSVForm(NetBoxModelCSVForm): @@ -159,7 +158,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): 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): @@ -204,7 +203,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): 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', ) @@ -257,7 +256,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 +297,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 @@ -326,7 +325,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments', 'tags') class VLANGroupCSVForm(NetBoxModelCSVForm): @@ -351,7 +350,7 @@ 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', } @@ -389,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm): 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', @@ -404,7 +403,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') class ServiceCSVForm(NetBoxModelCSVForm): @@ -427,7 +426,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): 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): @@ -443,7 +442,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') class L2VPNTerminationCSVForm(NetBoxModelCSVForm): @@ -480,7 +479,7 @@ class L2VPNTerminationCSVForm(NetBoxModelCSVForm): 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 a2ff7085b..7d277b33b 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', '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', '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', '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', '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', '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', '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', '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,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) @@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('VLAN ID', ('min_vid', 'max_vid')), ) @@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( @@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -511,8 +510,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', '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 92% rename from netbox/ipam/forms/models.py rename to netbox/ipam/forms/model_forms.py index dea42065c..9a5abc082 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/model_forms.py @@ -3,17 +3,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError 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 +49,7 @@ class VRFForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), @@ -61,8 +60,8 @@ 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", @@ -77,17 +76,24 @@ 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', ] class RIRForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('RIR', ( + 'name', 'slug', 'is_private', 'description', 'tags', + )), + ) + class Meta: model = RIR fields = [ @@ -100,6 +106,7 @@ class AggregateForm(TenancyForm, NetBoxModelForm): queryset=RIR.objects.all(), label='RIR' ) + comments = CommentField() fieldsets = ( ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), @@ -109,7 +116,7 @@ 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", @@ -130,6 +137,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): label='Sites', required=False ) + comments = CommentField() fieldsets = ( ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), @@ -139,7 +147,7 @@ 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", @@ -164,6 +172,12 @@ class ASNForm(TenancyForm, NetBoxModelForm): class RoleForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Role', ( + 'name', 'slug', 'weight', 'description', 'tags', + )), + ) + class Meta: model = Role fields = [ @@ -225,6 +239,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')), @@ -235,8 +250,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(), @@ -253,6 +268,7 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), @@ -262,7 +278,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(), @@ -384,13 +401,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, 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(), @@ -525,6 +543,7 @@ class FHRPGroupForm(NetBoxModelForm): required=False, label='Status' ) + comments = CommentField() fieldsets = ( ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')), @@ -535,11 +554,13 @@ class FHRPGroupForm(NetBoxModelForm): class Meta: model = FHRPGroup fields = ( - 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', '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', ) def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) + user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object() # Check if we need to create a new IPAddress for the group if self.cleaned_data.get('ip_address'): @@ -553,7 +574,7 @@ class FHRPGroupForm(NetBoxModelForm): ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions - if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first(): raise PermissionsViolation() return instance @@ -756,11 +777,13 @@ 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", @@ -783,10 +806,17 @@ class ServiceTemplateForm(NetBoxModelForm): ), help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." ) + comments = CommentField() + + fieldsets = ( + ('Service Template', ( + 'name', 'protocol', 'ports', 'description', 'tags', + )), + ) class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'tags') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') widgets = { 'protocol': StaticSelect(), } @@ -817,11 +847,12 @@ class ServiceForm(NetBoxModelForm): '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 " @@ -882,6 +913,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), @@ -892,7 +924,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(), 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/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 633affa41..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,7 +15,7 @@ __all__ = ( ) -class FHRPGroup(NetBoxModel): +class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ @@ -41,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', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 456bab4f0..bf9bd6d7f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -61,37 +61,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 - ) 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. @@ -101,10 +86,6 @@ class ASN(NetBoxModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) - description = models.CharField( - max_length=200, - blank=True - ) rir = models.ForeignKey( to='ipam.RIR', on_delete=models.PROTECT, @@ -154,7 +135,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. @@ -177,10 +158,6 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'rir', 'tenant', 'date_added', 'description', @@ -265,24 +242,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 @@ -291,7 +256,7 @@ 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 @@ -354,10 +319,6 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): default=False, help_text="Treat as 100% utilized" ) - description = models.CharField( - max_length=200, - blank=True - ) # Cached depth & child counts _depth = models.PositiveSmallIntegerField( @@ -572,7 +533,7 @@ 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. """ @@ -614,10 +575,6 @@ class IPRange(NetBoxModel): null=True, help_text='The primary function of this range' ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'vrf', 'tenant', 'status', 'role', 'description', @@ -767,7 +724,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 @@ -840,10 +797,6 @@ class IPAddress(NetBoxModel): verbose_name='DNS Name', help_text='Hostname or FQDN (not case-sensitive)' ) - description = models.CharField( - max_length=200, - blank=True - ) objects = IPAddressManager() diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index a457f334b..f3f7a1d55 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -8,7 +8,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 +16,7 @@ __all__ = ( ) -class L2VPN(NetBoxModel): +class L2VPN(PrimaryModel): name = models.CharField( max_length=100, unique=True @@ -43,10 +43,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, diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 70ad38197..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. @@ -92,6 +88,8 @@ class Service(ServiceBase, NetBoxModel): verbose_name='IP addresses' ) + clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ] + class Meta: ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index c8c401e1c..4f5d513cf 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -8,12 +8,10 @@ from django.urls import reverse 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', @@ -63,10 +61,6 @@ class VLANGroup(OrganizationalModel): ), help_text='Highest permissible ID of a child VLAN' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('name', 'pk') # Name may be non-unique @@ -83,9 +77,6 @@ class VLANGroup(OrganizationalModel): 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]) @@ -123,7 +114,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, @@ -175,10 +166,6 @@ class VLAN(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index a926bec3e..0f3c9793c 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -2,7 +2,7 @@ from django.db import models from django.urls import reverse from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( @@ -11,7 +11,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 @@ -40,10 +40,6 @@ class VRF(NetBoxModel): verbose_name='Enforce unique space', help_text='Prevent duplicate prefixes/IP addresses within this VRF' ) - description = models.CharField( - max_length=200, - blank=True - ) import_targets = models.ManyToManyField( to='ipam.RouteTarget', related_name='importing_vrfs', @@ -73,7 +69,7 @@ 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. """ @@ -82,10 +78,6 @@ class RouteTarget(NetBoxModel): unique=True, help_text='Route target value (formatted in accordance with RFC 4360)' ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py new file mode 100644 index 000000000..d1d25da76 --- /dev/null +++ b/netbox/ipam/search.py @@ -0,0 +1,139 @@ +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), + ) + + +@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), + ) + + +@register_search +class IPAddressIndex(SearchIndex): + model = models.IPAddress + fields = ( + ('address', 100), + ('dns_name', 300), + ('description', 500), + ) + + +@register_search +class IPRangeIndex(SearchIndex): + model = models.IPRange + fields = ( + ('start_address', 100), + ('end_address', 300), + ('description', 500), + ) + + +@register_search +class L2VPNIndex(SearchIndex): + model = models.L2VPN + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class PrefixIndex(SearchIndex): + model = models.Prefix + fields = ( + ('prefix', 100), + ('description', 500), + ) + + +@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), + ) + + +@register_search +class ServiceIndex(SearchIndex): + model = models.Service + fields = ( + ('name', 100), + ('description', 500), + ) + + +@register_search +class VLANIndex(SearchIndex): + model = models.VLAN + fields = ( + ('name', 100), + ('vid', 100), + ('description', 500), + ) + + +@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), + ) diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index beffdd232..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,7 +36,7 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'comments', 'ip_addresses', 'member_count', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a820385ed..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', @@ -375,9 +380,10 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): ) assigned = columns.BooleanColumn( accessor='assigned_object_id', - linkify=True, + 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 4a6af7c9b..2ece2c434 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -29,12 +29,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): template_code=L2VPN_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='ipam:prefix_list' + ) class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'actions', + 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'description', 'comments', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions') 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_views.py b/netbox/ipam/tests/test_views.py index 5cc8fad24..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') @@ -535,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, } @@ -552,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') @@ -576,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', } @@ -605,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') @@ -632,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, @@ -647,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') @@ -670,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', @@ -689,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') @@ -715,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', @@ -751,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): @@ -773,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', @@ -804,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), @@ -817,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, } @@ -836,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 04d07e356..df307c99f 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,11 +59,13 @@ 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() @@ -95,6 +100,7 @@ class RouteTargetListView(generic.ObjectListView): table = tables.RouteTargetTable +@register_model_view(RouteTarget) class RouteTargetView(generic.ObjectView): queryset = RouteTarget.objects.all() @@ -114,11 +120,13 @@ 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() @@ -155,6 +163,7 @@ class RIRListView(generic.ObjectListView): table = tables.RIRTable +@register_model_view(RIR) class RIRView(generic.ObjectView): queryset = RIR.objects.all() @@ -170,11 +179,13 @@ 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() @@ -216,6 +227,7 @@ class ASNListView(generic.ObjectListView): table = tables.ASNTable +@register_model_view(ASN) class ASNView(generic.ObjectView): queryset = ASN.objects.all() @@ -240,11 +252,13 @@ 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() @@ -285,16 +299,23 @@ 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' + ) def get_children(self, request, parent): return Prefix.objects.restrict(request.user, 'view').filter( @@ -311,18 +332,19 @@ 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() @@ -365,6 +387,7 @@ class RoleListView(generic.ObjectListView): table = tables.RoleTable +@register_model_view(Role) class RoleView(generic.ObjectView): queryset = Role.objects.all() @@ -381,11 +404,13 @@ 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() @@ -420,6 +445,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 +492,18 @@ 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' + ) def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( @@ -488,19 +520,24 @@ 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' + ) def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( @@ -510,17 +547,22 @@ 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' + ) def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') @@ -533,16 +575,17 @@ 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() @@ -577,31 +620,35 @@ 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' + ) 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() @@ -636,6 +683,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 +751,7 @@ class IPAddressView(generic.ObjectView): } +@register_model_view(IPAddress, 'edit') class IPAddressEditView(generic.ObjectEditView): queryset = IPAddress.objects.all() form = forms.IPAddressForm @@ -772,6 +821,7 @@ class IPAddressAssignView(generic.ObjectView): }) +@register_model_view(IPAddress, 'delete') class IPAddressDeleteView(generic.ObjectDeleteView): queryset = IPAddress.objects.all() @@ -816,6 +866,7 @@ class VLANGroupListView(generic.ObjectListView): table = tables.VLANGroupTable +@register_model_view(VLANGroup) class VLANGroupView(generic.ObjectView): queryset = VLANGroup.objects.all() @@ -846,11 +897,13 @@ 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() @@ -891,6 +944,7 @@ class FHRPGroupListView(generic.ObjectListView): table = tables.FHRPGroupTable +@register_model_view(FHRPGroup) class FHRPGroupView(generic.ObjectView): queryset = FHRPGroup.objects.all() @@ -915,6 +969,7 @@ class FHRPGroupView(generic.ObjectView): } +@register_model_view(FHRPGroup, 'edit') class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() form = forms.FHRPGroupForm @@ -930,7 +985,14 @@ class FHRPGroupEditView(generic.ObjectEditView): return return_url + def alter_object(self, obj, request, url_args, url_kwargs): + # Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that + # we can evaluate permissions during the creation of a new IPAddress within the form. + obj._user = request.user + return obj + +@register_model_view(FHRPGroup, 'delete') class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() @@ -958,6 +1020,7 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView): # FHRP group assignments # +@register_model_view(FHRPGroupAssignment, 'edit') class FHRPGroupAssignmentEditView(generic.ObjectEditView): queryset = FHRPGroupAssignment.objects.all() form = forms.FHRPGroupAssignmentForm @@ -971,6 +1034,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): return instance +@register_model_view(FHRPGroupAssignment, 'delete') class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = FHRPGroupAssignment.objects.all() @@ -986,6 +1050,7 @@ class VLANListView(generic.ObjectListView): table = tables.VLANTable +@register_model_view(VLAN) class VLANView(generic.ObjectView): queryset = VLAN.objects.all() @@ -1000,44 +1065,48 @@ 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' + ) 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' + ) 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() @@ -1072,15 +1141,18 @@ 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() @@ -1115,6 +1187,7 @@ class ServiceListView(generic.ObjectListView): table = tables.ServiceTable +@register_model_view(Service) class ServiceView(generic.ObjectView): queryset = Service.objects.all() @@ -1125,12 +1198,14 @@ 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() @@ -1156,7 +1231,6 @@ class ServiceBulkDeleteView(generic.BulkDeleteView): # L2VPN - class L2VPNListView(generic.ObjectListView): queryset = L2VPN.objects.all() table = L2VPNTable @@ -1164,6 +1238,7 @@ class L2VPNListView(generic.ObjectListView): filterset_form = forms.L2VPNFilterForm +@register_model_view(L2VPN) class L2VPNView(generic.ObjectView): queryset = L2VPN.objects.all() @@ -1188,11 +1263,13 @@ 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() @@ -1216,6 +1293,10 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable +# +# L2VPN terminations +# + class L2VPNTerminationListView(generic.ObjectListView): queryset = L2VPNTermination.objects.all() table = L2VPNTerminationTable @@ -1223,16 +1304,19 @@ 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() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index b8607a0bb..814ca1ed6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication): if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - + user = token.user # When LDAP authentication is active try to load user data from LDAP directory if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() # Load from LDAP if FIND_GROUP_PERMS is active - if ldap_backend.settings.FIND_GROUP_PERMS: - user = ldap_backend.populate_user(token.user.username) + # Always query LDAP when user is not active, otherwise it is never activated again + if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active: + ldap_user = ldap_backend.populate_user(token.user.username) # If the user is found in the LDAP directory use it, if not fallback to the local user - if user: - return user, token + if ldap_user: + user = ldap_user - return token.user, token + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return user, token class TokenPermissions(DjangoObjectPermissions): diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c50ad9ca6..e5e842696 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 @@ -142,7 +142,9 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali """ 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/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 7dc1111f3..b47c88a4e 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -108,6 +108,5 @@ class ObjectValidationMixin: conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() if conforming_count != len(instance): raise ObjectDoesNotExist - else: - # Check that the instance is matched by the view's queryset - self.queryset.get(pk=instance.pk) + elif not self.queryset.filter(pk=instance.pk).exists(): + raise ObjectDoesNotExist diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index b1f4858c7..a7e56e279 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -351,6 +351,14 @@ class LDAPBackend: if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False): ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + # Optionally set CA cert directory + if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None): + ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir) + + # Optionally set CA cert file + if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None): + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file) + return obj diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index ad0dcc7c3..b3b6fbb6c 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..c8054b3b0 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,5 +1,2 @@ # Prefix for nested serializers NESTED_SERIALIZER_PREFIX = 'Nested' - -# Max results per object type -SEARCH_MAX_RESULTS = 15 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..02ccdca50 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field +from django.shortcuts import get_object_or_404 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 +47,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 +81,28 @@ 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: + data = data.copy() # Get a mutable copy + saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter')) + 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 +112,7 @@ class BaseFilterSet(django_filters.FilterSet): filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, + filters.MultiValueDecimalFilter, filters.MultiValueTimeFilter )): return FILTER_NUMERIC_BASED_LOOKUP_MAP diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index d1451e003..dd1fb7726 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,45 +1,45 @@ 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')), +) 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() diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2676e4cde..4a4368a65 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -1,12 +1,13 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import Q -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices -from extras.forms.customfields import CustomFieldsMixin +from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices +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', @@ -61,7 +62,17 @@ class NetBoxModelCSVForm(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) + 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( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ) def _get_form_field(self, customfield): return customfield.to_form_field(for_csv_import=True) @@ -109,7 +120,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. @@ -124,11 +135,20 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): 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'].widget.add_query_params({ + 'content_type_id': content_type.pk, + }) + def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).exclude( + return super()._get_custom_fields(content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(type=CustomFieldTypeChoices.TYPE_JSON) ) def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False) + return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False) 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/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/models/__init__.py b/netbox/netbox/models/__init__.py index aefb733b4..661470ee0 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -9,8 +10,9 @@ from netbox.models.features import * __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', - 'OrganizationalModel', 'NetBoxModel', + 'OrganizationalModel', + 'PrimaryModel', ) @@ -27,6 +29,10 @@ class NetBoxFeatureSet( class Meta: abstract = True + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' + @classmethod def get_prerequisite_models(cls): """ @@ -53,7 +59,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ - Primary models represent real objects within the infrastructure being modeled. + Base model for most object types. Suitable for use by plugins. """ objects = RestrictedQuerySet.as_manager() @@ -61,6 +67,22 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): abstract = True +class PrimaryModel(NetBoxModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + abstract = True + + class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest @@ -77,6 +99,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 @@ -130,3 +155,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 9fa1c5cef..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', @@ -124,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 ) @@ -132,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): """ @@ -154,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: @@ -171,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() @@ -292,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/menu.py b/netbox/netbox/navigation/menu.py index 400a7bf5a..60c0657ae 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from . import * @@ -278,6 +278,7 @@ OTHER_MENU = Menu( 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( @@ -294,6 +295,11 @@ OTHER_MENU = Menu( link_text='Scripts', permissions=['extras.view_script'] ), + MenuItem( + link='extras:jobresult_list', + link_text='Job Results', + permissions=['extras.view_jobresult'], + ), ), ), MenuGroup( diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 6bf56b562..95fd101c3 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator 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..82fff68c6 --- /dev/null +++ b/netbox/netbox/search/__init__.py @@ -0,0 +1,115 @@ +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' + + +class SearchIndex: + """ + Base class for building search indexes. + + Attrs: + 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 cfd4d231c..1046812e8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,35 +1,29 @@ 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 - # # Environment setup # -VERSION = '3.3.5-dev' +VERSION = '3.4-beta1' # Hostname HOSTNAME = platform.node() @@ -77,6 +71,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: @@ -85,6 +80,7 @@ CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') +CSRF_COOKIE_PATH = BASE_PATH or '/' CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') @@ -122,6 +118,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) @@ -129,6 +126,8 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') +SESSION_COOKIE_PATH = BASE_PATH or '/' +LANGUAGE_COOKIE_PATH = BASE_PATH or '/' SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') @@ -186,7 +185,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( @@ -387,10 +386,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' @@ -498,7 +496,7 @@ for param in dir(configuration): # Force usage of PostgreSQL's JSONB field for extra data SOCIAL_AUTH_JSONFIELD_ENABLED = True - +SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username' # # Django Prometheus @@ -649,7 +647,6 @@ RQ_QUEUES = { # for plugin_name in PLUGINS: - # Import plugin module try: plugin = importlib.import_module(plugin_name) @@ -663,14 +660,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/tables/columns.py b/netbox/netbox/tables/columns.py index c7545192a..5e92196e5 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -300,7 +300,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: @@ -319,7 +319,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..b6f732bfe --- /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.forms.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/views/__init__.py b/netbox/netbox/views/__init__.py index bc1f0e2ca..bfad4af99 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -1,16 +1,19 @@ import platform import sys +from collections import namedtuple from django.conf import settings +from django.contrib.contenttypes.models import ContentType 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 django_tables2 import RequestConfig from packaging import version from sentry_sdk import capture_message @@ -21,103 +24,95 @@ from dcim.models import ( 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 Tenant +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 +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") + return redirect('login') - connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).count + power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).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 = ( - ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), - ("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count), + 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 = ( - ("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count), - ("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count), - ("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count), + 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 = ( - ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count), - ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count), - ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count), - ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count), - ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count), - ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count) - + 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 = ( - ("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count), - ("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count), + 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 = ( - ("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count), - ("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count), - + 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 = ( - ("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count), - ("dcim.view_consoleport", "Console", connected_consoleports.count), - ("dcim.view_interface", "Interfaces", connected_interfaces.count), - ("dcim.view_powerport", "Power Connections", connected_powerports.count), + 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 = ( - ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), - ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), + 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 = ( - ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), - ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + 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)), ) - sections = ( - ("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"), + 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'), ) - stats = [] - for section_label, section_items, icon_class in sections: - items = [] - for perm, item_label, get_count in section_items: - app, scope = perm.split(".") - url = ":".join((app, scope.replace("view_", "") + "_list")) - item = { - "label": item_label, - "count": None, - "url": url, - "disabled": True, - "icon": icon_class, - } - if request.user.has_perm(perm): - item["count"] = get_count() - item["disabled"] = False - items.append(item) - stats.append((section_label, items, icon_class)) - return stats # Compile changelog table @@ -149,39 +144,48 @@ class HomeView(View): class SearchView(View): def get(self, request): - form = SearchForm(request.GET) results = [] + highlight = None + + # Initialize search form + form = SearchForm(request.GET) if 'q' in request.GET else SearchForm() 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']}") + # 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)) - for obj_type in SEARCH_TYPES.keys(): + 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 + ) - 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'] + if form.cleaned_data['lookup'] != LookupTypes.EXACT: + highlight = form.cleaned_data['q'] - # 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) + table = SearchTable(results, highlight=highlight) - if table.page: - results.append({ - 'name': queryset.model._meta.verbose_name_plural, - 'table': table, - 'url': f"{reverse(url)}?q={form.cleaned_data.get('q')}" - }) + # 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, - 'results': results, + 'table': table, }) diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3ad3bcf67..8e49ea62f 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -1,18 +1,40 @@ +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 -class BaseObjectView(ObjectPermissionRequiredMixin, View): +class BaseView(ObjectPermissionRequiredMixin, View): + queryset = 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() + + +class BaseObjectView(BaseView): """ - 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 template_name: The name of the HTML template file to render """ - queryset = None template_name = None def get_object(self, **kwargs): @@ -35,16 +57,15 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): return {} -class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): +class BaseMultiObjectView(BaseView): """ - 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 table: The django-tables2 Table class used to render the objects list template_name: The name of the HTML template file to render """ - queryset = None table = None template_name = None diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 77c2ee822..fd1e98677 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -11,8 +11,8 @@ from django.db.models.fields.reverse_related import ManyToManyRel 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 @@ -21,11 +21,10 @@ from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViol from utilities.forms import ( BulkRenameForm, ConfirmationForm, ImportForm, FileUploadImportForm, restrict_form_fields, ) - +from utilities.forms.choices import ImportFormatChoices from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin -from utilities.forms.choices import ImportFormatChoices from .base import BaseMultiObjectView from .mixins import ActionsMixin, TableMixin from .utils import get_prerequisite_model @@ -128,7 +127,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) @@ -144,7 +143,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 @@ -299,6 +298,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): 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 @@ -306,6 +308,15 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ return data + def _get_records(self, form, request): + headers = form.cleaned_data['headers'] + if request.FILES: + records = form.cleaned_data['data_file'] + else: + records = form.cleaned_data['data'] + + return headers, records + def _create_object(self, request, model_form): # Save the primary object @@ -380,15 +391,46 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return new_objs + def _update_objects(self, form, request, headers, records): + updated_objs = [] + + ids = [int(record["id"]) for record in records] + qs = self.queryset.model.objects.filter(id__in=ids) + objs = {} + for obj in qs: + objs[obj.id] = obj + + for row, data in enumerate(records, start=1): + if int(data["id"]) not in objs: + form.add_error('csv', f'Row {row} id: {data["id"]} Does not exist') + raise ValidationError("") + + obj = objs[int(data["id"])] + obj_form = self.model_form(data, headers=headers, instance=obj) + + # The form should only contain fields that are in the CSV + for name, field in list(obj_form.fields.items()): + if name not in headers: + del obj_form.fields[name] + + restrict_form_fields(obj_form, request.user) + + if obj_form.is_valid(): + obj = self._save_obj(obj_form, request) + updated_objs.append(obj) + else: + for field, err in obj_form.errors.items(): + form.add_error('data', f'Row {row} {field}: {err[0]}') + raise ValidationError("") + + return updated_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() - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - # # Request handlers # @@ -431,7 +473,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): try: # Iterate through data and bind each record to a new model form instance. with transaction.atomic(): - new_objs = self._create_objects(form, request) + if form.cleaned_data['format'] == 'csv': + headers, records = self._get_records(form, request) + if 'id' in headers: + new_objs = self._update_objects(form, request, headers, records) + else: + new_objs = self._create_objects(form, request) + else: + new_objs = self._create_objects(form, request) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): @@ -551,7 +600,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') @@ -748,7 +797,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')] @@ -835,7 +884,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..ce5b29eb2 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,10 @@ 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' + ) def get(self, request, model, **kwargs): @@ -56,7 +62,7 @@ class ObjectChangeLogView(View): 'object': obj, 'table': objectchanges_table, 'base_template': self.base_template, - 'active_tab': 'changelog', + 'tab': self.tab, }) @@ -71,6 +77,11 @@ 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' + ) def get(self, request, model, **kwargs): @@ -111,5 +122,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 79087b4cb..738d70786 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 @@ -37,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') @@ -66,6 +69,7 @@ class ObjectView(BaseObjectView): return render(request, self.get_template_name(), { 'object': instance, + 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -120,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) @@ -140,6 +144,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): 'child_model': self.child_model, 'table': table, 'actions': actions, + 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -249,7 +254,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): obj = form.save() # Check that the new object conforms with any assigned object-level permissions - if not self.queryset.filter(pk=obj.pk).first(): + if not self.queryset.filter(pk=obj.pk).exists(): raise PermissionsViolation() msg = '{} {}'.format( diff --git a/netbox/project-static/.eslintrc b/netbox/project-static/.eslintrc index 802fa7a3e..30b47308b 100644 --- a/netbox/project-static/.eslintrc +++ b/netbox/project-static/.eslintrc @@ -31,8 +31,7 @@ } }, "rules": { - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-unused-vars-experimental": "error", + "@typescript-eslint/no-unused-vars": "error", "no-unused-vars": "off", "no-inner-declarations": "off", "comma-dangle": ["error", "always-multiline"], diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index ff431f4ad..a5f5ff7e9 100644 Binary files a/netbox/project-static/dist/cable_trace.css and b/netbox/project-static/dist/cable_trace.css differ diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index 2b360c5a2..cda30523c 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index 937913e20..0ca9fb89e 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/graphiql.css b/netbox/project-static/dist/graphiql.css index 02230c6ee..a20e480d3 100644 Binary files a/netbox/project-static/dist/graphiql.css and b/netbox/project-static/dist/graphiql.css differ diff --git a/netbox/project-static/dist/graphiql.js b/netbox/project-static/dist/graphiql.js index 53803747c..0d4b3288b 100644 Binary files a/netbox/project-static/dist/graphiql.js and b/netbox/project-static/dist/graphiql.js differ diff --git a/netbox/project-static/dist/graphiql.js.map b/netbox/project-static/dist/graphiql.js.map index 9a8e8eaad..fd9688c34 100644 Binary files a/netbox/project-static/dist/graphiql.js.map and b/netbox/project-static/dist/graphiql.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index c77672ff6..da3c3bd46 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 1835a3525..a36df817f 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff b/netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff new file mode 100644 index 000000000..deb8ae9c9 Binary files /dev/null and b/netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff differ diff --git a/netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2 b/netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2 new file mode 100644 index 000000000..c8cadcfdb Binary files /dev/null and b/netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2 differ diff --git a/netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot b/netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot new file mode 100644 index 000000000..ee030ec7a Binary files /dev/null and b/netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot differ diff --git a/netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf b/netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf new file mode 100644 index 000000000..d68551087 Binary files /dev/null and b/netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf differ diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 6f6c3904d..8b6b37a4c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css index f8bd6e21c..8164a7fa8 100644 Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 0a95db07f..27e804c30 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 4ae1302d3..f0220c050 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 6d0aa1535..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 6c04b9849..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/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 229ea2f97..32b3b687f 100644 Binary files a/netbox/project-static/dist/rack_elevation.css and b/netbox/project-static/dist/rack_elevation.css differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js index 5d970d6b9..a6a5534ec 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index c630420fd..a773c5600 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 256696947..8258c2be4 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -22,43 +22,38 @@ "validate:formatting:scripts": "prettier -c src/**/*.ts" }, "dependencies": { - "@mdi/font": "^5.9.55", - "@popperjs/core": "^2.9.2", + "@mdi/font": "^7.0.96", + "@popperjs/core": "^2.11.6", "bootstrap": "~5.0.2", - "clipboard": "^2.0.8", - "color2k": "^1.2.4", - "dayjs": "^1.10.4", - "flatpickr": "4.6.3", - "htmx.org": "^1.6.1", - "just-debounce-it": "^1.4.0", + "clipboard": "^2.0.11", + "color2k": "^2.0.0", + "dayjs": "^1.11.5", + "flatpickr": "4.6.13", + "htmx.org": "^1.8.0", + "just-debounce-it": "^3.1.1", "masonry-layout": "^4.2.2", - "query-string": "^6.14.1", - "sass": "^1.32.8", - "simplebar": "^5.3.4", - "slim-select": "^1.27.0" + "query-string": "^7.1.1", + "sass": "^1.55.0", + "simplebar": "^5.3.9", + "slim-select": "^1.27.1" }, "devDependencies": { - "@types/bootstrap": "^5.0.12", - "@types/cookie": "^0.4.0", - "@types/masonry-layout": "^4.2.2", - "@typescript-eslint/eslint-plugin": "^4.29.3", - "@typescript-eslint/parser": "^4.29.3", - "esbuild": "^0.12.24", - "esbuild-sass-plugin": "^1.5.2", - "eslint": "^7.32.0", - "eslint-config-prettier": "^8.3.0", - "eslint-import-resolver-typescript": "^2.4.0", - "eslint-plugin-import": "^2.24.2", - "eslint-plugin-prettier": "^3.4.1", - "prettier": "^2.3.2", - "typescript": "~4.3.5" + "@types/bootstrap": "^5.0.17", + "@types/cookie": "^0.5.1", + "@types/masonry-layout": "^4.2.5", + "@typescript-eslint/eslint-plugin": "^5.39.0", + "@typescript-eslint/parser": "^5.39.0", + "esbuild": "^0.13.15", + "esbuild-sass-plugin": "^2.3.3", + "eslint": "^8.24.0", + "eslint-config-prettier": "^8.5.0", + "eslint-import-resolver-typescript": "^3.5.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", + "prettier": "^2.7.1", + "typescript": "~4.8.4" }, "resolutions": { - "eslint-import-resolver-typescript/**/path-parse": "^1.0.7", - "slim-select/**/trim-newlines": "^3.0.1", - "eslint/glob-parent": "^5.1.2", - "esbuild-sass-plugin/**/glob-parent": "^5.1.2", - "@typescript-eslint/**/glob-parent": "^5.1.2", - "eslint-plugin-import/**/hosted-git-info": "^2.8.9" + "@types/bootstrap/**/@popperjs/core": "^2.11.6" } -} +} \ No newline at end of file diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index c178a2dbd..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, @@ -37,6 +37,18 @@ function initDocument(): void { } function initWindow(): void { + const documentForms = document.forms; + for (const documentForm of documentForms) { + if (documentForm.method.toUpperCase() == 'GET') { + documentForm.addEventListener('formdata', function (event: FormDataEvent) { + const formData: FormData = event.formData; + for (const [name, value] of Array.from(formData.entries())) { + if (value === '') formData.delete(name); + } + }); + } + } + const contentContainer = document.querySelector('.content-container'); if (contentContainer !== null) { // Focus the content container for accessible navigation. 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/project-static/styles/select.scss b/netbox/project-static/styles/select.scss index 675b0e722..d8fcd218e 100644 --- a/netbox/project-static/styles/select.scss +++ b/netbox/project-static/styles/select.scss @@ -32,7 +32,7 @@ $spacing-s: $input-padding-x; } } -@import './node_modules/slim-select/src/slim-select/slimselect'; +@import '../node_modules/slim-select/src/slim-select/slimselect'; .ss-main { color: $form-select-color; diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 8e21446c6..9dca72d25 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -2,123 +2,59 @@ # yarn lockfile v1 -"@babel/code-frame@7.12.11": - version "7.12.11" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" - integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== +"@ardatan/sync-fetch@0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@ardatan/sync-fetch/-/sync-fetch-0.0.1.tgz#3385d3feedceb60a896518a1db857ec1e945348f" + integrity sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA== dependencies: - "@babel/highlight" "^7.10.4" + node-fetch "^2.6.1" -"@babel/code-frame@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb" - integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw== +"@babel/code-frame@^7.0.0": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" + integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== dependencies: - "@babel/highlight" "^7.14.5" + "@babel/highlight" "^7.18.6" -"@babel/generator@^7.15.0": - version "7.15.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15" - integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ== +"@babel/helper-validator-identifier@^7.18.6": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" + integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== + +"@babel/highlight@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" + integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== dependencies: - "@babel/types" "^7.15.0" - jsesc "^2.5.1" - source-map "^0.5.0" - -"@babel/helper-function-name@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4" - integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ== - dependencies: - "@babel/helper-get-function-arity" "^7.14.5" - "@babel/template" "^7.14.5" - "@babel/types" "^7.14.5" - -"@babel/helper-get-function-arity@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815" - integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg== - dependencies: - "@babel/types" "^7.14.5" - -"@babel/helper-hoist-variables@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d" - integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ== - dependencies: - "@babel/types" "^7.14.5" - -"@babel/helper-split-export-declaration@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a" - integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA== - dependencies: - "@babel/types" "^7.14.5" - -"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9": - version "7.14.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48" - integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g== - -"@babel/highlight@^7.10.4", "@babel/highlight@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9" - integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg== - dependencies: - "@babel/helper-validator-identifier" "^7.14.5" + "@babel/helper-validator-identifier" "^7.18.6" chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.14.5", "@babel/parser@^7.15.0", "@babel/parser@^7.3.1": - version "7.15.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862" - integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA== - -"@babel/template@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" - integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g== +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== dependencies: - "@babel/code-frame" "^7.14.5" - "@babel/parser" "^7.14.5" - "@babel/types" "^7.14.5" + "@jridgewell/trace-mapping" "0.3.9" -"@babel/traverse@^7.2.3": - version "7.15.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98" - integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw== - dependencies: - "@babel/code-frame" "^7.14.5" - "@babel/generator" "^7.15.0" - "@babel/helper-function-name" "^7.14.5" - "@babel/helper-hoist-variables" "^7.14.5" - "@babel/helper-split-export-declaration" "^7.14.5" - "@babel/parser" "^7.15.0" - "@babel/types" "^7.15.0" - debug "^4.1.0" - globals "^11.1.0" +"@esbuild/linux-loong64@0.14.54": + version "0.14.54" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028" + integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw== -"@babel/types@^7.14.5", "@babel/types@^7.15.0": - version "7.15.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd" - integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ== - dependencies: - "@babel/helper-validator-identifier" "^7.14.9" - to-fast-properties "^2.0.0" - -"@eslint/eslintrc@^0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" - integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw== +"@eslint/eslintrc@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.2.tgz#58b69582f3b7271d8fa67fe5251767a5b38ea356" + integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ== dependencies: ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^13.9.0" - ignore "^4.0.6" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^3.13.1" - minimatch "^3.0.4" + js-yaml "^4.1.0" + minimatch "^3.1.2" strip-json-comments "^3.1.1" "@graphiql/toolkit@^0.2.0": @@ -132,29 +68,180 @@ optionalDependencies: subscriptions-transport-ws "^0.9.18" -"@humanwhocodes/config-array@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" - integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg== +"@graphql-tools/batch-execute@8.5.6": + version "8.5.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-8.5.6.tgz#476c2f9af1c302567e798d63063f1a2dfaad754a" + integrity sha512-33vMvVDLBKsNJVNhcySVXF+zkcRL/GRs1Lt+MxygrYCypcAPpFm+amE2y9vOCFufuaKExIX7Lonnmxu19vPzaQ== dependencies: - "@humanwhocodes/object-schema" "^1.2.0" + "@graphql-tools/utils" "8.12.0" + dataloader "2.1.0" + tslib "^2.4.0" + value-or-promise "1.0.11" + +"@graphql-tools/delegate@9.0.8": + version "9.0.8" + resolved "https://registry.yarnpkg.com/@graphql-tools/delegate/-/delegate-9.0.8.tgz#aa792f419a041de0c6341eaecf9694cf6f16f76f" + integrity sha512-h+Uce0Np0eKj7wILOvlffRQ9jEQ4KelNXfqG8A2w+2sO2P6CbKsR7bJ4ch9lcUdCBbZ4Wg6L/K+1C4NRFfzbNw== + dependencies: + "@graphql-tools/batch-execute" "8.5.6" + "@graphql-tools/schema" "9.0.4" + "@graphql-tools/utils" "8.12.0" + dataloader "2.1.0" + tslib "~2.4.0" + value-or-promise "1.0.11" + +"@graphql-tools/graphql-file-loader@^7.3.7": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-7.5.5.tgz#d8afb391282e3dc33005df04bdec8488e4ab101d" + integrity sha512-OL+7qO1S66TpMK7OGz8Ag2WL08HlxKxrObVSDlxzWbSubWuXM5v959XscYAKRf6daYcVpkfNvO37QjflL9mjhg== + dependencies: + "@graphql-tools/import" "6.7.6" + "@graphql-tools/utils" "8.12.0" + globby "^11.0.3" + tslib "^2.4.0" + unixify "^1.0.0" + +"@graphql-tools/import@6.7.6": + version "6.7.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-6.7.6.tgz#9bc0bb304a6fcc43aa2c9177631670b1fdfb2115" + integrity sha512-WtUyiO2qCaK/H4u81zAw/NbBvCOzwKl4N+Vl+FqrFCzYobscwL6x6roePyoXM1O3+JJIIn3CETv4kg4kwxaBVw== + dependencies: + "@graphql-tools/utils" "8.12.0" + resolve-from "5.0.0" + tslib "^2.4.0" + +"@graphql-tools/json-file-loader@^7.3.7": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-7.4.6.tgz#d4135c3e15491eda653f58ed89efbd5e21d0b1b8" + integrity sha512-34AfjCitO4NtJ5AcXYLcFF3GDsMVTycrljSaBA2t1d7B4bMPtREDphKXLMc/Uf2zW6IW1i1sZZyrcmArPy1Z8A== + dependencies: + "@graphql-tools/utils" "8.12.0" + globby "^11.0.3" + tslib "^2.4.0" + unixify "^1.0.0" + +"@graphql-tools/load@^7.5.5": + version "7.7.7" + resolved "https://registry.yarnpkg.com/@graphql-tools/load/-/load-7.7.7.tgz#0d6fb0804177658f609562982a6a68e008073ca0" + integrity sha512-IpI2672zcoAX4FLjcH5kvHc7eqjPyLP1svrIcZKQenv0GRS6dW0HI9E5UCBs0y/yy8yW6s+SvpmNsfIlkMj3Kw== + dependencies: + "@graphql-tools/schema" "9.0.4" + "@graphql-tools/utils" "8.12.0" + p-limit "3.1.0" + tslib "^2.4.0" + +"@graphql-tools/merge@8.3.6", "@graphql-tools/merge@^8.2.6": + version "8.3.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.6.tgz#97a936d4c8e8f935e58a514bb516c476437b5b2c" + integrity sha512-uUBokxXi89bj08P+iCvQk3Vew4vcfL5ZM6NTylWi8PIpoq4r5nJ625bRuN8h2uubEdRiH8ntN9M4xkd/j7AybQ== + dependencies: + "@graphql-tools/utils" "8.12.0" + tslib "^2.4.0" + +"@graphql-tools/schema@9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.4.tgz#1a74608b57abf90fae6fd929d25e5482c57bc05d" + integrity sha512-B/b8ukjs18fq+/s7p97P8L1VMrwapYc3N2KvdG/uNThSazRRn8GsBK0Nr+FH+mVKiUfb4Dno79e3SumZVoHuOQ== + dependencies: + "@graphql-tools/merge" "8.3.6" + "@graphql-tools/utils" "8.12.0" + tslib "^2.4.0" + value-or-promise "1.0.11" + +"@graphql-tools/url-loader@^7.9.7": + version "7.16.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-7.16.4.tgz#d27787ef9f35fe71b456c067c3a1759b1ecd76a8" + integrity sha512-7yGrJJNcqVQIplCyVLk7tW2mAgYyZ06FRmCBnzw3B61+aIjFavrm6YlnKkhdqYSYyFmIbVcigdP3vkoYIu23TA== + dependencies: + "@ardatan/sync-fetch" "0.0.1" + "@graphql-tools/delegate" "9.0.8" + "@graphql-tools/utils" "8.12.0" + "@graphql-tools/wrap" "9.2.3" + "@types/ws" "^8.0.0" + "@whatwg-node/fetch" "^0.4.0" + dset "^3.1.2" + extract-files "^11.0.0" + graphql-ws "^5.4.1" + isomorphic-ws "^5.0.0" + meros "^1.1.4" + tslib "^2.4.0" + value-or-promise "^1.0.11" + ws "^8.3.0" + +"@graphql-tools/utils@8.12.0", "@graphql-tools/utils@^8.6.5": + version "8.12.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.12.0.tgz#243bc4f5fc2edbc9e8fd1038189e57d837cbe31f" + integrity sha512-TeO+MJWGXjUTS52qfK4R8HiPoF/R7X+qmgtOYd8DTH0l6b+5Y/tlg5aGeUJefqImRq7nvi93Ms40k/Uz4D5CWw== + dependencies: + tslib "^2.4.0" + +"@graphql-tools/wrap@9.2.3": + version "9.2.3" + resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-9.2.3.tgz#70f7602aed9781fbc860cea64a918636599883be" + integrity sha512-aiLjcAuUwcvA1mF25c7KFDPXEdQDpo6bTDyAMCSlFXpF4T01hoxLERmfmbRmsmy/dP80ZB31a+t70aspVdqZSA== + dependencies: + "@graphql-tools/delegate" "9.0.8" + "@graphql-tools/schema" "9.0.4" + "@graphql-tools/utils" "8.12.0" + tslib "^2.4.0" + value-or-promise "1.0.11" + +"@humanwhocodes/config-array@^0.10.5": + version "0.10.7" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.7.tgz#6d53769fd0c222767e6452e8ebda825c22e9f0dc" + integrity sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" -"@humanwhocodes/object-schema@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" - integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + +"@iarna/toml@^2.2.5": + version "2.2.5" + resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" + integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" "@juggle/resize-observer@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0" integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw== -"@mdi/font@^5.9.55": - version "5.9.55" - resolved "https://registry.yarnpkg.com/@mdi/font/-/font-5.9.55.tgz#41acd50b88073ded7095fc3029d8712b6e12f38e" - integrity sha512-jswRF6q3eq8NWpWiqct6q+6Fg/I7nUhrxYJfiEM8JJpap0wVJLQdbKtyS65GdlK7S7Ytnx3TTi/bmw+tBhkGmg== +"@mdi/font@^7.0.96": + version "7.0.96" + resolved "https://registry.yarnpkg.com/@mdi/font/-/font-7.0.96.tgz#9853c222623072f5575b4039c8c195ea929b61fc" + integrity sha512-rzlxTfR64hqY8yiBzDjmANfcd8rv+T5C0Yedv/TWk2QyAQYdc66e0kaN1ipmnYU3RukHRTRcBARHzzm+tIhL7w== "@n1ru4l/push-pull-async-iterable-iterator@^2.1.4": version "2.1.4" @@ -182,23 +269,81 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@popperjs/core@^2.9.2": - version "2.9.3" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.3.tgz#8b68da1ebd7fc603999cf6ebee34a4899a14b88e" - integrity sha512-xDu17cEfh7Kid/d95kB6tZsLOmSWKCZKtprnhVepjsSaCij+lM3mItSJDuuHDMbCWTh8Ejmebwb+KONcCJ0eXQ== +"@peculiar/asn1-schema@^2.1.6": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.0.tgz#5368416eb336138770c692ffc2bab119ee3ae917" + integrity sha512-DtNLAG4vmDrdSJFPe7rypkcj597chNQL7u+2dBtYo5mh7VW2+im6ke+O0NVr8W1f4re4C3F71LhoMb0Yxqa48Q== + dependencies: + asn1js "^3.0.5" + pvtsutils "^1.3.2" + tslib "^2.4.0" -"@types/bootstrap@^5.0.12": - version "5.1.2" - resolved "https://registry.yarnpkg.com/@types/bootstrap/-/bootstrap-5.1.2.tgz#24f08f1957ff5859633f4bf620e921d296e6c3a2" - integrity sha512-dSQvMi2dMyNwJU6LZjP0pimuBowsMUvGScYdfqqeiDUoj9TxXZCpfu0cTl94U0Zvw/tdH9j/9ToOhi4LKNLZhg== +"@peculiar/json-schema@^1.1.12": + version "1.1.12" + resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" + integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== + dependencies: + tslib "^2.0.0" + +"@peculiar/webcrypto@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.4.0.tgz#f941bd95285a0f8a3d2af39ccda5197b80cd32bf" + integrity sha512-U58N44b2m3OuTgpmKgf0LPDOmP3bhwNz01vAnj1mBwxBASRhptWYK+M3zG+HBkDqGQM+bFsoIihTW8MdmPXEqg== + dependencies: + "@peculiar/asn1-schema" "^2.1.6" + "@peculiar/json-schema" "^1.1.12" + pvtsutils "^1.3.2" + tslib "^2.4.0" + webcrypto-core "^1.7.4" + +"@pkgr/utils@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.1.tgz#0a9b06ffddee364d6642b3cd562ca76f55b34a03" + integrity sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw== + dependencies: + cross-spawn "^7.0.3" + is-glob "^4.0.3" + open "^8.4.0" + picocolors "^1.0.0" + tiny-glob "^0.2.9" + tslib "^2.4.0" + +"@popperjs/core@^2.11.6", "@popperjs/core@^2.9.2": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@types/bootstrap@^5.0.17": + version "5.2.5" + resolved "https://registry.yarnpkg.com/@types/bootstrap/-/bootstrap-5.2.5.tgz#0bb5dea7720611b2bb7ba16bd8a64fafd86fb658" + integrity sha512-VnalUJ3E/oaV3DYrauEc/sSPpaEPxTV09twSEzY4KFRvyuGlrZUSqG95XZ6ReAi0YMZIs7rXxdngDK2X1YONQA== dependencies: "@popperjs/core" "^2.9.2" - "@types/jquery" "*" -"@types/cookie@^0.4.0": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" - integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookie@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554" + integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g== "@types/jquery@*": version "3.5.6" @@ -207,106 +352,165 @@ dependencies: "@types/sizzle" "*" -"@types/json-schema@^7.0.7": +"@types/json-schema@7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/json-schema@^7.0.9": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/masonry-layout@^4.2.2": - version "4.2.4" - resolved "https://registry.yarnpkg.com/@types/masonry-layout/-/masonry-layout-4.2.4.tgz#f4d82c98b09e4828d4d39b0a70f16cc485d494dc" - integrity sha512-0X08KnKsD4kyC5irjS9NhkMdG6R5UGMgfUS/0Gq3d1N8NS6DB6P+ApsnUbZKcW8NRW9xNyCkOd1maw/UgL+u0g== +"@types/masonry-layout@^4.2.5": + version "4.2.5" + resolved "https://registry.yarnpkg.com/@types/masonry-layout/-/masonry-layout-4.2.5.tgz#1d6180ec07ad21df7f85d20ac6e99b061854d00f" + integrity sha512-/DSIDMDsXlS+7JfzywA2zm9x+veO/z1QDcjKLS4OhDZH7sFdreKJbNFKb7jYA3NrY3bRvFGCamp5+DeIArLzow== dependencies: "@types/jquery" "*" +"@types/node@*": + version "18.8.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.1.tgz#33e6759935f7a82821b72fb936e66f6b99a36173" + integrity sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ== + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + "@types/sizzle@*": version "2.3.3" resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== -"@typescript-eslint/eslint-plugin@^4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.3.tgz#95cb8029a8bd8bd9c7f4ab95074a7cb2115adefa" - integrity sha512-tBgfA3K/3TsZY46ROGvoRxQr1wBkclbVqRQep97MjVHJzcRBURRY3sNFqLk0/Xr//BY5hM9H2p/kp+6qim85SA== +"@types/ws@^8.0.0": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== dependencies: - "@typescript-eslint/experimental-utils" "4.29.3" - "@typescript-eslint/scope-manager" "4.29.3" - debug "^4.3.1" - functional-red-black-tree "^1.0.1" - regexpp "^3.1.0" - semver "^7.3.5" + "@types/node" "*" + +"@typescript-eslint/eslint-plugin@^5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz#778b2d9e7f293502c7feeea6c74dca8eb3e67511" + integrity sha512-xVfKOkBm5iWMNGKQ2fwX5GVgBuHmZBO1tCRwXmY5oAIsPscfwm2UADDuNB8ZVYCtpQvJK4xpjrK7jEhcJ0zY9A== + dependencies: + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/type-utils" "5.39.0" + "@typescript-eslint/utils" "5.39.0" + debug "^4.3.4" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/experimental-utils@4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.29.3.tgz#52e437a689ccdef73e83c5106b34240a706f15e1" - integrity sha512-ffIvbytTVWz+3keg+Sy94FG1QeOvmV9dP2YSdLFHw/ieLXWCa3U1TYu8IRCOpMv2/SPS8XqhM1+ou1YHsdzKrg== +"@typescript-eslint/parser@^5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.39.0.tgz#93fa0bc980a3a501e081824f6097f7ca30aaa22b" + integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA== dependencies: - "@types/json-schema" "^7.0.7" - "@typescript-eslint/scope-manager" "4.29.3" - "@typescript-eslint/types" "4.29.3" - "@typescript-eslint/typescript-estree" "4.29.3" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.39.0.tgz#873e1465afa3d6c78d8ed2da68aed266a08008d0" + integrity sha512-/I13vAqmG3dyqMVSZPjsbuNQlYS082Y7OMkwhCfLXYsmlI0ca4nkL7wJ/4gjX70LD4P8Hnw1JywUVVAwepURBw== + dependencies: + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" + +"@typescript-eslint/type-utils@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.39.0.tgz#0a8c00f95dce4335832ad2dc6bc431c14e32a0a6" + integrity sha512-KJHJkOothljQWzR3t/GunL0TPKY+fGJtnpl+pX+sJ0YiKTz3q2Zr87SGTmFqsCMFrLt5E0+o+S6eQY0FAXj9uA== + dependencies: + "@typescript-eslint/typescript-estree" "5.39.0" + "@typescript-eslint/utils" "5.39.0" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.39.0.tgz#f4e9f207ebb4579fd854b25c0bf64433bb5ed78d" + integrity sha512-gQMZrnfEBFXK38hYqt8Lkwt8f4U6yq+2H5VDSgP/qiTzC8Nw8JO3OuSUOQ2qW37S/dlwdkHDntkZM6SQhKyPhw== + +"@typescript-eslint/typescript-estree@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.39.0.tgz#c0316aa04a1a1f4f7f9498e3c13ef1d3dc4cf88b" + integrity sha512-qLFQP0f398sdnogJoLtd43pUgB18Q50QSA+BTE5h3sUxySzbWDpTSdgt4UyxNSozY/oDK2ta6HVAzvGgq8JYnA== + dependencies: + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.39.0.tgz#b7063cca1dcf08d1d21b0d91db491161ad0be110" + integrity sha512-+DnY5jkpOpgj+EBtYPyHRjXampJfC0yUZZzfzLuUWVZvCuKqSdJVC8UhdWipIw7VKNTfwfAPiOWzYkAwuIhiAg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/parser@^4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.29.3.tgz#2ac25535f34c0e98f50c0e6b28c679c2357d45f2" - integrity sha512-jrHOV5g2u8ROghmspKoW7pN8T/qUzk0+DITun0MELptvngtMrwUJ1tv5zMI04CYVEUsSrN4jV7AKSv+I0y0EfQ== +"@typescript-eslint/visitor-keys@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.39.0.tgz#8f41f7d241b47257b081ddba5d3ce80deaae61e2" + integrity sha512-yyE3RPwOG+XJBLrhvsxAidUgybJVQ/hG8BhiJo0k8JSAYfk/CshVcxf0HwP4Jt7WZZ6vLmxdo1p6EyN3tzFTkg== dependencies: - "@typescript-eslint/scope-manager" "4.29.3" - "@typescript-eslint/types" "4.29.3" - "@typescript-eslint/typescript-estree" "4.29.3" - debug "^4.3.1" + "@typescript-eslint/types" "5.39.0" + eslint-visitor-keys "^3.3.0" -"@typescript-eslint/scope-manager@4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.29.3.tgz#497dec66f3a22e459f6e306cf14021e40ec86e19" - integrity sha512-x+w8BLXO7iWPkG5mEy9bA1iFRnk36p/goVlYobVWHyDw69YmaH9q6eA+Fgl7kYHmFvWlebUTUfhtIg4zbbl8PA== +"@whatwg-node/fetch@^0.4.0": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.4.7.tgz#4cbcda3ba93d5ae15ab823aae5869eae4a0cb14b" + integrity sha512-+oKDMGtmUJ7H37VDL5U2Vdk+ZxsIypZxO2q6y42ytu6W3PL6OIIUYZGliNqQgWtCdtxOZ9WPQvbIAuiLpnLlUw== dependencies: - "@typescript-eslint/types" "4.29.3" - "@typescript-eslint/visitor-keys" "4.29.3" + "@peculiar/webcrypto" "^1.4.0" + abort-controller "^3.0.0" + busboy "^1.6.0" + form-data-encoder "^1.7.1" + formdata-node "^4.3.1" + node-fetch "^2.6.7" + undici "^5.10.0" + web-streams-polyfill "^3.2.0" -"@typescript-eslint/types@4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.29.3.tgz#d7980c49aef643d0af8954c9f14f656b7fd16017" - integrity sha512-s1eV1lKNgoIYLAl1JUba8NhULmf+jOmmeFO1G5MN/RBCyyzg4TIOfIOICVNC06lor+Xmy4FypIIhFiJXOknhIg== - -"@typescript-eslint/typescript-estree@4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.29.3.tgz#1bafad610015c4ded35c85a70b6222faad598b40" - integrity sha512-45oQJA0bxna4O5TMwz55/TpgjX1YrAPOI/rb6kPgmdnemRZx/dB0rsx+Ku8jpDvqTxcE1C/qEbVHbS3h0hflag== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== dependencies: - "@typescript-eslint/types" "4.29.3" - "@typescript-eslint/visitor-keys" "4.29.3" - debug "^4.3.1" - globby "^11.0.3" - is-glob "^4.0.1" - semver "^7.3.5" - tsutils "^3.21.0" + event-target-shim "^5.0.0" -"@typescript-eslint/visitor-keys@4.29.3": - version "4.29.3" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.29.3.tgz#c691760a00bd86bf8320d2a90a93d86d322f1abf" - integrity sha512-MGGfJvXT4asUTeVs0Q2m+sY63UsfnA+C/FDgBKV3itLBmM9H0u+URcneePtkd0at1YELmZK6HSolCqM4Fzs6yA== - dependencies: - "@typescript-eslint/types" "4.29.3" - eslint-visitor-keys "^2.0.0" - -acorn-jsx@^5.3.1: +acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^7.4.0: - version "7.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1, acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== ajv@^6.10.0, ajv@^6.12.4: version "6.12.6" @@ -318,66 +522,19 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.1: - version "8.6.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.2.tgz#2fb45e0e5fcbc0813326c1c3da535d1881bb0571" - integrity sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-align@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" - integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38= - dependencies: - string-width "^2.0.0" - -ansi-colors@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== - -ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= - -ansi-regex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== - -ansi-regex@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" - integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= - -ansi-styles@^3.2.0, ansi-styles@^3.2.1: +ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -392,6 +549,11 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -399,75 +561,55 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" -array-find-index@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" - integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -array-includes@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" - integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== +array-includes@^3.1.4: + version "3.1.5" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb" + integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" get-intrinsic "^1.1.1" - is-string "^1.0.5" - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= - dependencies: - array-uniq "^1.0.1" + is-string "^1.0.7" array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -array-uniq@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= - -array.prototype.flat@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" - integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== +array.prototype.flat@^1.2.5: + version "1.3.0" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b" + integrity sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw== dependencies: - call-bind "^1.0.0" + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" + es-abstract "^1.19.2" + es-shim-unscopables "^1.0.0" -arrify@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= - -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +asn1js@^3.0.1, asn1js@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== + dependencies: + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== -babel-runtime@^6.6.1: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - backo2@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" - integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= + integrity sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA== balanced-match@^1.0.0: version "1.0.2" @@ -484,19 +626,6 @@ bootstrap@~5.0.2: resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.0.2.tgz#aff23d5e0e03c31255ad437530ee6556e78e728e" integrity sha512-1Ge963tyEQWJJ+8qtXFU6wgmAVj9gweEjibUdbmcCEYsn38tVwRk8107rk2vzt6cfQcRr3SlZ8aQBqaD8aqf+Q== -boxen@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" - integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw== - dependencies: - ansi-align "^2.0.0" - camelcase "^4.0.0" - chalk "^2.0.1" - cli-boxes "^1.0.0" - string-width "^2.0.0" - term-size "^1.2.0" - widest-line "^2.0.0" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -512,10 +641,12 @@ braces@^3.0.1, braces@~3.0.2: dependencies: fill-range "^7.0.1" -builtin-modules@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" - integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA== +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" @@ -525,73 +656,17 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" -callsite-record@^3.0.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/callsite-record/-/callsite-record-3.2.2.tgz#9a0390642e43fe8bb823945e51464f69f41643de" - integrity sha1-mgOQZC5D/ou4I5ReUUZPafQWQ94= - dependencies: - callsite "^1.0.0" - chalk "^1.1.1" - error-stack-parser "^1.3.3" - highlight-es "^1.0.0" - lodash "4.6.1 || ^4.16.1" - pinkie-promise "^2.0.0" - -callsite@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" - integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" - integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc= - dependencies: - camelcase "^2.0.0" - map-obj "^1.0.0" - -camelcase@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" - integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= - -camelcase@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= - -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - can-use-dom@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a" integrity sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo= -capture-stack-trace@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d" - integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw== - -chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.0: +chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -623,73 +698,26 @@ chalk@^4.0.0: optionalDependencies: fsevents "~2.3.2" -ci-info@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" - integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== - -cli-boxes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" - integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= - -cli-cursor@^1.0.1, cli-cursor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc= - dependencies: - restore-cursor "^1.0.1" - -cli-spinners@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" - integrity sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw= - -cli-width@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" - integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== - -clipboard@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba" - integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ== +clipboard@^2.0.11: + version "2.0.11" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5" + integrity sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw== dependencies: good-listener "^1.2.2" select "^1.1.2" tiny-emitter "^2.0.0" -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - codemirror-graphql@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/codemirror-graphql/-/codemirror-graphql-1.0.2.tgz#cfbfb4ab9ed81467dc606848c5eb84e1f5d82766" - integrity sha512-D4+BdYa6iQnDlio4mBk1Yap5ROCqEWapSFLkiKGatx/I0dF6euzdwd0um3Ndudw6rFQbNuT7hpcH8tnBO6VOfQ== + version "1.3.2" + resolved "https://registry.yarnpkg.com/codemirror-graphql/-/codemirror-graphql-1.3.2.tgz#e9d1d18b4a160f0016a28465805284636ee42d2a" + integrity sha512-glwFsEVlH5TvxjSKGymZ1sNy37f3Mes58CB4fXOd0zy9+JzDL08Wti1b5ycy4vFZYghMDK1/Or/zRSjMAGtC2w== dependencies: - graphql-language-service-interface "^2.8.2" - graphql-language-service-parser "^1.9.0" + graphql-language-service "^5.0.6" codemirror@^5.54.0: - version "5.62.3" - resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.62.3.tgz#5cfdee6931c8b2d1b39ae773aaaaec2cc6b5558e" - integrity sha512-zZAyOfN8TU67ngqrxhOgtkSAGV9jSpN1snbl8elPtnh9Z5A11daR405+dhLzLnuXrwX0WCShWlybxPN3QC/9Pg== + version "5.65.9" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.9.tgz#ec70c92aa206ee4c9853d5f1e7c4ed356cdab68c" + integrity sha512-19Jox5sAKpusTDgqgKB5dawPpQcY+ipQK7xoEI+MVucEF9qqFaXpeqY1KaoyGBso/wHQoDa4HMMxMjdsS3Zzzw== color-convert@^1.9.0: version "1.9.3" @@ -708,35 +736,23 @@ color-convert@^2.0.1: color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color2k@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/color2k/-/color2k-1.2.4.tgz#af34950ac58e23cf224a01cb8dd0c9911a79605e" - integrity sha512-DiwdBwc0BryPFFXoCrW8XQGXl2rEtMToODybxZjKnN5IJXt/tV/FsN02pCK/b7KcWvJEioz3c74lQSmayFvS4Q== +color2k@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.0.tgz#86992c82e248c29f524023ed0822bc152c4fa670" + integrity sha512-DWX9eXOC4fbJNiuvdH4QSHvvfLWyFo9TuFp7V9OzdsbPAdrWAuYc8qvFP2bIQ/LKh4LrAVnJ6vhiQYPvAHdtTg== concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -configstore@^3.0.0: - version "3.1.5" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f" - integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA== - dependencies: - dot-prop "^4.2.1" - graceful-fs "^4.1.2" - make-dir "^1.0.0" - unique-string "^1.0.0" - write-file-atomic "^2.0.0" - xdg-basedir "^3.0.0" - copy-to-clipboard@^3.2.0: version "3.3.1" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" @@ -744,41 +760,40 @@ copy-to-clipboard@^3.2.0: dependencies: toggle-selection "^1.0.6" -core-js@^2.4.0: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - core-js@^3.0.1: version "3.16.4" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.4.tgz#0fb1029a554fc2688c0963d7c900e188188a78e0" integrity sha512-Tq4GVE6XCjE+hcyW6hPy0ofN3hwtLudz5ZRdrlCnsnD/xkm/PWQRudzYHiKgZKUcefV6Q57fhDHjZHJP5dpfSg== -create-error-class@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" - integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y= +cosmiconfig-toml-loader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig-toml-loader/-/cosmiconfig-toml-loader-1.0.0.tgz#0681383651cceff918177debe9084c0d3769509b" + integrity sha512-H/2gurFWVi7xXvCyvsWRLCMekl4tITJcX0QEsDMpzxtuxDyM59xLatYNg4s/k9AA/HdtCYfj2su8mgA0GSDLDA== dependencies: - capture-stack-trace "^1.0.0" + "@iarna/toml" "^2.2.5" -cross-spawn-async@^2.1.1: - version "2.2.5" - resolved "https://registry.yarnpkg.com/cross-spawn-async/-/cross-spawn-async-2.2.5.tgz#845ff0c0834a3ded9d160daca6d390906bb288cc" - integrity sha1-hF/wwINKPe2dFg2sptOQkGuyiMw= +cosmiconfig-typescript-loader@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.1.1.tgz#38dd3578344038dae40fdf09792bc2e9df529f78" + integrity sha512-9DHpa379Gp0o0Zefii35fcmuuin6q92FnLDffzdZ0l9tVd3nEobG3O+MZ06+kuBvFTSVScvNb/oHA13Nd4iipg== + +cosmiconfig@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" + integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== dependencies: - lru-cache "^4.0.0" - which "^1.2.8" + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-spawn@^7.0.2: +cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -787,27 +802,15 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-random-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" - integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= +dataloader@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.1.0.tgz#c69c538235e85e7ac6c6c444bae8ecabf5de9df7" + integrity sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ== -currently-unhandled@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" - integrity sha1-mI3zP+qxke95mmE2nddsF635V+o= - dependencies: - array-find-index "^1.0.1" - -dayjs@^1.10.4: - version "1.10.6" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.6.tgz#288b2aa82f2d8418a6c9d4df5898c0737ad02a63" - integrity sha512-AztC/IOW4L1Q41A86phW5Thhcrco3xuAA+YX/BLpLWWjRcTj5TOt/QImBLmCKlrF7u7k47arTnOyL6GnbG8Hvw== - -de-indent@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= +dayjs@^1.11.5: + version "1.11.5" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93" + integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA== debug@^2.6.9: version "2.6.9" @@ -823,33 +826,35 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: +debug@^4.1.1: version "4.3.2" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== dependencies: ms "2.1.2" -decamelize@^1.1.2, decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - deep-is@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -857,47 +862,29 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +define-properties@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" + integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== + dependencies: + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + delegate@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== -depcheck@0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/depcheck/-/depcheck-0.8.3.tgz#430aad19016820cfe7b0766ee561817fcdea5835" - integrity sha512-xcLTnaovCFFTts5Ge7mUUhMGHSu6eRfftvVvOjN7gXO5EFUhJfX6UQa1b08a0SIwKfzG9eKNn5mzZlXp0mZARA== - dependencies: - "@babel/parser" "^7.3.1" - "@babel/traverse" "^7.2.3" - builtin-modules "^3.0.0" - deprecate "^1.0.0" - deps-regex "^0.1.4" - js-yaml "^3.4.2" - lodash "^4.17.11" - minimatch "^3.0.2" - node-sass-tilde-importer "^1.0.2" - please-upgrade-node "^3.1.1" - require-package-name "^2.0.1" - resolve "^1.10.0" - vue-template-compiler "^2.6.10" - walkdir "^0.3.2" - yargs "^13.2.2" - -deprecate@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/deprecate/-/deprecate-1.1.1.tgz#4632e981fc815eeaf00be945a40359c0f8bf9913" - integrity sha512-ZGDXefq1xknT292LnorMY5s8UVU08/WKdzDZCUT6t9JzsiMSP4uzUhgpqugffNVcT5WC6wMBiSQ+LFjlv3v7iQ== - -deps-regex@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deps-regex/-/deps-regex-0.1.4.tgz#518667b7691460a5e7e0a341be76eb7ce8090184" - integrity sha1-UYZnt2kUYKXn4KNBvnbrfOgJAYQ= - desandro-matches-selector@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz#717beed4dc13e7d8f3762f707a6d58a6774218e1" integrity sha1-cXvu1NwT59jzdi9wem1YpndCGOE= +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -919,39 +906,18 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dot-prop@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4" - integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ== +dset@^3.1.0, dset@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" + integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== + +enhanced-resolve@^5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" + integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== dependencies: - is-obj "^1.0.0" - -dset@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.0.tgz#23feb6df93816ea452566308b1374d6e869b0d7b" - integrity sha512-7xTQ5DzyE59Nn+7ZgXDXjKAGSGmXZHqttMVVz1r4QNfmGpyj+cm2YtI3II0c/+4zS4a9yq2mBhgdeq2QnpcYlw== - -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -enquirer@^2.3.5: - version "2.3.6" - resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" - integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== - dependencies: - ansi-colors "^4.1.1" + graceful-fs "^4.2.4" + tapable "^2.2.0" entities@^2.0.0: version "2.2.0" @@ -963,42 +929,49 @@ entities@~2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== -error-ex@^1.2.0, error-ex@^1.3.1: +error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== dependencies: is-arrayish "^0.2.1" -error-stack-parser@^1.3.3: - version "1.3.6" - resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-1.3.6.tgz#e0e73b93e417138d1cd7c0b746b1a4a14854c292" - integrity sha1-4Oc7k+QXE40c18C3RrGkoUhUwpI= - dependencies: - stackframe "^0.3.1" - -es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2: - version "1.18.5" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.5.tgz#9b10de7d4c206a3581fd5b2124233e04db49ae19" - integrity sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA== +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: + version "1.20.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.3.tgz#90b143ff7aedc8b3d189bcfac7f1e3e3f81e9da1" + integrity sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" - get-intrinsic "^1.1.1" + function.prototype.name "^1.1.5" + get-intrinsic "^1.1.3" + get-symbol-description "^1.0.0" has "^1.0.3" - has-symbols "^1.0.2" + has-property-descriptors "^1.0.0" + has-symbols "^1.0.3" internal-slot "^1.0.3" - is-callable "^1.2.3" - is-negative-zero "^2.0.1" - is-regex "^1.1.3" - is-string "^1.0.6" - object-inspect "^1.11.0" + is-callable "^1.2.6" + is-negative-zero "^2.0.2" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + is-string "^1.0.7" + is-weakref "^1.0.2" + object-inspect "^1.12.2" object-keys "^1.1.1" - object.assign "^4.1.2" - string.prototype.trimend "^1.0.4" - string.prototype.trimstart "^1.0.4" - unbox-primitive "^1.0.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.4.3" + safe-regex-test "^1.0.0" + string.prototype.trimend "^1.0.5" + string.prototype.trimstart "^1.0.5" + unbox-primitive "^1.0.2" + +es-shim-unscopables@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" + integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== + dependencies: + has "^1.0.3" es-to-primitive@^1.2.1: version "1.2.1" @@ -1009,35 +982,264 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-sass-plugin@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-1.5.2.tgz#57dbc94bc6b20531bb7143ecb2f69ea8d2fa8dcd" - integrity sha512-u8K9F3MMz9L+MNtvMvKx8BiBNjc+7XjDXT2bGaEpyrqPVaR0B7zrMCeYPPAE37KGffntM9Wji2YyGAQOI9L4kA== +esbuild-android-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be" + integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ== + +esbuild-android-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44" + integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg== + +esbuild-android-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771" + integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg== + +esbuild-darwin-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72" + integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ== + +esbuild-darwin-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25" + integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug== + +esbuild-darwin-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a" + integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ== + +esbuild-darwin-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73" + integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw== + +esbuild-freebsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85" + integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA== + +esbuild-freebsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d" + integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg== + +esbuild-freebsd-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52" + integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ== + +esbuild-freebsd-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48" + integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q== + +esbuild-linux-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69" + integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g== + +esbuild-linux-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5" + integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw== + +esbuild-linux-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz#9cb8e4bcd7574e67946e4ee5f1f1e12386bb6dd3" + integrity sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA== + +esbuild-linux-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652" + integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg== + +esbuild-linux-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1" + integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA== + +esbuild-linux-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b" + integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig== + +esbuild-linux-arm@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe" + integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA== + +esbuild-linux-arm@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59" + integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw== + +esbuild-linux-mips64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7" + integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg== + +esbuild-linux-mips64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34" + integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw== + +esbuild-linux-ppc64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2" + integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ== + +esbuild-linux-ppc64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e" + integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ== + +esbuild-linux-riscv64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8" + integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg== + +esbuild-linux-s390x@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6" + integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA== + +esbuild-netbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038" + integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w== + +esbuild-netbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81" + integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w== + +esbuild-openbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7" + integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g== + +esbuild-openbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b" + integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw== + +esbuild-sass-plugin@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-2.3.3.tgz#0b35eb6ba82caca8f85eed4163d2f2df2f7f7d1c" + integrity sha512-EegGnUIsP5Y7FbwcGBD524F+cJaIAQU2LSOX9QtjgpqEmwnmfEh5f/aPJ1df5GxD3NgHQJspeRCV7spDHE3N6Q== dependencies: - esbuild "^0.12.20" - picomatch "^2.3.0" - resolve "^1.20.0" - sass "^1.37.5" + esbuild "^0.14.13" + resolve "^1.22.1" + sass "^1.49.0" -esbuild@^0.12.20, esbuild@^0.12.24: - version "0.12.24" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.24.tgz#21966fad25a80f368ed308101e88102bce0dc68f" - integrity sha512-C0ibY+HsXzYB6L/pLWEiWjMpghKsIc58Q5yumARwBQsHl9DXPakW+5NI/Y9w4YXiz0PEP6XTGTT/OV4Nnsmb4A== +esbuild-sunos-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4" + integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw== -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: +esbuild-sunos-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da" + integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw== + +esbuild-windows-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7" + integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw== + +esbuild-windows-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31" + integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w== + +esbuild-windows-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294" + integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ== + +esbuild-windows-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4" + integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ== + +esbuild-windows-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3" + integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA== + +esbuild-windows-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982" + integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg== + +esbuild@^0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.15.tgz#db56a88166ee373f87dbb2d8798ff449e0450cdf" + integrity sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw== + optionalDependencies: + esbuild-android-arm64 "0.13.15" + esbuild-darwin-64 "0.13.15" + esbuild-darwin-arm64 "0.13.15" + esbuild-freebsd-64 "0.13.15" + esbuild-freebsd-arm64 "0.13.15" + esbuild-linux-32 "0.13.15" + esbuild-linux-64 "0.13.15" + esbuild-linux-arm "0.13.15" + esbuild-linux-arm64 "0.13.15" + esbuild-linux-mips64le "0.13.15" + esbuild-linux-ppc64le "0.13.15" + esbuild-netbsd-64 "0.13.15" + esbuild-openbsd-64 "0.13.15" + esbuild-sunos-64 "0.13.15" + esbuild-windows-32 "0.13.15" + esbuild-windows-64 "0.13.15" + esbuild-windows-arm64 "0.13.15" + +esbuild@^0.14.13: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2" + integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA== + optionalDependencies: + "@esbuild/linux-loong64" "0.14.54" + esbuild-android-64 "0.14.54" + esbuild-android-arm64 "0.14.54" + esbuild-darwin-64 "0.14.54" + esbuild-darwin-arm64 "0.14.54" + esbuild-freebsd-64 "0.14.54" + esbuild-freebsd-arm64 "0.14.54" + esbuild-linux-32 "0.14.54" + esbuild-linux-64 "0.14.54" + esbuild-linux-arm "0.14.54" + esbuild-linux-arm64 "0.14.54" + esbuild-linux-mips64le "0.14.54" + esbuild-linux-ppc64le "0.14.54" + esbuild-linux-riscv64 "0.14.54" + esbuild-linux-s390x "0.14.54" + esbuild-netbsd-64 "0.14.54" + esbuild-openbsd-64 "0.14.54" + esbuild-sunos-64 "0.14.54" + esbuild-windows-32 "0.14.54" + esbuild-windows-64 "0.14.54" + esbuild-windows-arm64 "0.14.54" + +escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-config-prettier@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz#f7471b20b6fe8a9a9254cc684454202886a2dd7a" - integrity sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew== +eslint-config-prettier@^8.5.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1" + integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== eslint-import-resolver-node@^0.3.6: version "0.3.6" @@ -1047,50 +1249,49 @@ eslint-import-resolver-node@^0.3.6: debug "^3.2.7" resolve "^1.20.0" -eslint-import-resolver-typescript@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.4.0.tgz#ec1e7063ebe807f0362a7320543aaed6fe1100e1" - integrity sha512-useJKURidCcldRLCNKWemr1fFQL1SzB3G4a0li6lFGvlc5xGe1hY343bvG07cbpCzPuM/lK19FIJB3XGFSkplA== +eslint-import-resolver-typescript@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.1.tgz#c72634da072eebd04fe73007fa58a62c333c8147" + integrity sha512-U7LUjNJPYjNsHvAUAkt/RU3fcTSpbllA0//35B4eLYTX74frmOepbt7F7J3D1IGtj9k21buOpaqtDd4ZlS/BYQ== dependencies: - debug "^4.1.1" - glob "^7.1.6" - is-glob "^4.0.1" - resolve "^1.17.0" - tsconfig-paths "^3.9.0" + debug "^4.3.4" + enhanced-resolve "^5.10.0" + get-tsconfig "^4.2.0" + globby "^13.1.2" + is-core-module "^2.10.0" + is-glob "^4.0.3" + synckit "^0.8.3" -eslint-module-utils@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz#94e5540dd15fe1522e8ffa3ec8db3b7fa7e7a534" - integrity sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q== +eslint-module-utils@^2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" + integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== dependencies: debug "^3.2.7" - pkg-dir "^2.0.0" -eslint-plugin-import@^2.24.2: - version "2.24.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz#2c8cd2e341f3885918ee27d18479910ade7bb4da" - integrity sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q== +eslint-plugin-import@^2.26.0: + version "2.26.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" + integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== dependencies: - array-includes "^3.1.3" - array.prototype.flat "^1.2.4" + array-includes "^3.1.4" + array.prototype.flat "^1.2.5" debug "^2.6.9" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.6" - eslint-module-utils "^2.6.2" - find-up "^2.0.0" + eslint-module-utils "^2.7.3" has "^1.0.3" - is-core-module "^2.6.0" - minimatch "^3.0.4" - object.values "^1.1.4" - pkg-up "^2.0.0" - read-pkg-up "^3.0.0" - resolve "^1.20.0" - tsconfig-paths "^3.11.0" + is-core-module "^2.8.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.values "^1.1.5" + resolve "^1.22.0" + tsconfig-paths "^3.14.1" -eslint-plugin-prettier@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5" - integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g== +eslint-plugin-prettier@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" + integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== dependencies: prettier-linter-helpers "^1.0.0" @@ -1102,12 +1303,13 @@ eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== dependencies: - eslint-visitor-keys "^1.1.0" + esrecurse "^4.3.0" + estraverse "^5.2.0" eslint-utils@^3.0.0: version "3.0.0" @@ -1116,75 +1318,69 @@ eslint-utils@^3.0.0: dependencies: eslint-visitor-keys "^2.0.0" -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== - eslint-visitor-keys@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint@^7.32.0: - version "7.32.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" - integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + +eslint@^8.24.0: + version "8.24.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.24.0.tgz#489516c927a5da11b3979dbfb2679394523383c8" + integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ== dependencies: - "@babel/code-frame" "7.12.11" - "@eslint/eslintrc" "^0.4.3" - "@humanwhocodes/config-array" "^0.5.0" + "@eslint/eslintrc" "^1.3.2" + "@humanwhocodes/config-array" "^0.10.5" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" - debug "^4.0.1" + debug "^4.3.2" doctrine "^3.0.0" - enquirer "^2.3.5" escape-string-regexp "^4.0.0" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.1" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.1.2" - globals "^13.6.0" - ignore "^4.0.6" + find-up "^5.0.0" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - js-yaml "^3.13.1" + js-sdsl "^4.1.4" + js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.0.4" + minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" + regexpp "^3.2.0" + strip-ansi "^6.0.1" strip-json-comments "^3.1.0" - table "^6.0.9" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^7.3.0, espree@^7.3.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" - integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: - acorn "^7.4.0" - acorn-jsx "^5.3.1" - eslint-visitor-keys "^1.3.0" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" esquery@^1.4.0: version "1.4.0" @@ -1220,46 +1416,20 @@ ev-emitter@^1.0.0: resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a" integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^3.1.0: version "3.1.2" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== -execa@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.2.2.tgz#e2ead472c2c31aad6f73f1ac956eef45e12320cb" - integrity sha1-4urUcsLDGq1vc/GslW7vReEjIMs= - dependencies: - cross-spawn-async "^2.1.1" - npm-run-path "^1.0.0" - object-assign "^4.0.1" - path-key "^1.0.0" - strip-eof "^1.0.0" - -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= - -expand-tilde@^2.0.0, expand-tilde@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" - integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= - dependencies: - homedir-polyfill "^1.0.1" +extract-files@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-11.0.0.tgz#b72d428712f787eef1f5193aff8ab5351ca8469a" + integrity sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ== fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" @@ -1271,10 +1441,10 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== -fast-glob@^3.1.1: - version "3.2.7" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" - integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q== +fast-glob@^3.2.11, fast-glob@^3.2.9: + version "3.2.12" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" + integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -1299,14 +1469,6 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -1326,32 +1488,13 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= -find-parent-dir@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.1.tgz#c5c385b96858c3351f95d446cab866cbf9f11125" - integrity sha512-o4UcykWV/XN9wm+jMEtWLPlV8RXCZnMhQI6F6OdHeSez7iiJWePw8ijOlskJZMsaQoGR/b7dH6lO02HhaTN7+A== - -find-up@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" - integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8= +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== dependencies: - path-exists "^2.0.0" - pinkie-promise "^2.0.0" - -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" + locate-path "^6.0.0" + path-exists "^4.0.0" fizzy-ui-utils@^2.0.0: version "2.0.7" @@ -1368,16 +1511,29 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flatpickr@4.6.3: - version "4.6.3" - resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.3.tgz#15a8b76b6e34e3a072861250503a5995b9d3bc60" - integrity sha512-007VucCkqNOMMb9ggRLNuJowwaJcyOh4sKAFcdGfahfGc7JQbf94zSzjdBq/wVyHWUEs5o3+idhFZ0wbZMRmVQ== +flatpickr@4.6.13: + version "4.6.13" + resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94" + integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== flatted@^3.1.0: version "3.2.2" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561" integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== +form-data-encoder@^1.7.1: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + +formdata-node@^4.3.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1393,15 +1549,20 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +function.prototype.name@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" + integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + functions-have-names "^1.2.2" -get-caller-file@^2.0.1: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +functions-have-names@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: version "1.1.1" @@ -1412,25 +1573,32 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-intrinsic@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385" + integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.3" + get-size@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/get-size/-/get-size-2.0.3.tgz#54a1d0256b20ea7ac646516756202769941ad2ef" integrity sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q== -get-stdin@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" - integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4= +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -giturl@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/giturl/-/giturl-1.0.1.tgz#926c69bda5c48a3d8f74254e99f826835e6a4aa0" - integrity sha512-wQourBdI13n8tbjcZTDl6k+ZrCRMU6p9vfp9jknZq+zfWc8xXNztpZFM4XkPHVzHcMSUZxEMYYKZjIGkPlei6Q== +get-tsconfig@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.2.0.tgz#ff368dd7104dab47bf923404eb93838245c66543" + integrity sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg== glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" @@ -1439,18 +1607,14 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@^6.0.1: - version "6.0.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" - integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" + is-glob "^4.0.3" -glob@^7.1.3, glob@^7.1.6: +glob@^7.1.3: version "7.1.7" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== @@ -1462,68 +1626,45 @@ glob@^7.1.3, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= - dependencies: - ini "^1.3.4" - -global-modules@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" - integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg== - dependencies: - global-prefix "^1.0.1" - is-windows "^1.0.1" - resolve-dir "^1.0.0" - -global-prefix@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" - integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4= - dependencies: - expand-tilde "^2.0.2" - homedir-polyfill "^1.0.1" - ini "^1.3.4" - is-windows "^1.0.1" - which "^1.2.14" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.6.0, globals@^13.9.0: - version "13.11.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.11.0.tgz#40ef678da117fe7bd2e28f1fab24951bd0255be7" - integrity sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g== +globals@^13.15.0: + version "13.17.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.17.0.tgz#902eb1e680a41da93945adbdcb5a9f361ba69bd4" + integrity sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw== dependencies: type-fest "^0.20.2" -globby@^11.0.3: - version "11.0.4" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" - integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== +globalyzer@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" + integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== + +globby@^11.0.3, globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== dependencies: array-union "^2.1.0" dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" slash "^3.0.0" -globby@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8" - integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg= +globby@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.2.tgz#29047105582427ab6eca4f905200667b056da515" + integrity sha512-LKSDZXToac40u8Q1PQtZihbNdTYSNMuWe+K5l+oa6KgDzSvVrHXlJy40hUP522RjAIoNLJYBJi7ow+rbFpIhHQ== dependencies: - array-union "^1.0.1" - arrify "^1.0.0" - glob "^6.0.1" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" + dir-glob "^3.0.1" + fast-glob "^3.2.11" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^4.0.0" + +globrex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" + integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== good-listener@^1.2.2: version "1.2.2" @@ -1532,27 +1673,15 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" -got@^6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0" - integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA= - dependencies: - create-error-class "^3.0.0" - duplexer3 "^0.1.4" - get-stream "^3.0.0" - is-redirect "^1.0.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - safe-buffer "^5.0.1" - timed-out "^4.0.0" - unzip-response "^2.0.1" - url-parse-lax "^1.0.0" +graceful-fs@^4.2.4: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.5: - version "4.2.8" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" - integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== graphiql@1.4.1: version "1.4.1" @@ -1568,81 +1697,130 @@ graphiql@1.4.1: graphql-language-service "^3.1.2" markdown-it "^10.0.0" -graphql-language-service-interface@^2.8.2: - version "2.8.4" - resolved "https://registry.yarnpkg.com/graphql-language-service-interface/-/graphql-language-service-interface-2.8.4.tgz#3ff31754e9b295b1abc26b97d286c00835aacff0" - integrity sha512-myW8z7HOZkYfhYGKDc0URFkTZChp41Po890W92zuBIhGccckgtiWSJGXaLX+r9QAwVIeZhKaNgEacsyvQb1f/g== +graphql-config@^4.1.0: + version "4.3.5" + resolved "https://registry.yarnpkg.com/graphql-config/-/graphql-config-4.3.5.tgz#bd197ec9c1e86d2696c61332be35b920ba7be700" + integrity sha512-B4jXhHL7j3llCem+ACeo48wvVYhtJxRyt5SfSnvywbRlVYyUzt5ibZV6WJU2Yii2/rcVRIGi7BHDgcAPWdWdJg== dependencies: - graphql-language-service-parser "^1.9.0" - graphql-language-service-types "^1.8.0" - graphql-language-service-utils "^2.5.1" + "@graphql-tools/graphql-file-loader" "^7.3.7" + "@graphql-tools/json-file-loader" "^7.3.7" + "@graphql-tools/load" "^7.5.5" + "@graphql-tools/merge" "^8.2.6" + "@graphql-tools/url-loader" "^7.9.7" + "@graphql-tools/utils" "^8.6.5" + cosmiconfig "7.0.1" + cosmiconfig-toml-loader "1.0.0" + cosmiconfig-typescript-loader "^4.0.0" + minimatch "4.2.1" + string-env-interpolation "1.0.1" + ts-node "^10.8.1" + tslib "^2.4.0" + +graphql-language-service-interface@^2.9.5: + version "2.10.2" + resolved "https://registry.yarnpkg.com/graphql-language-service-interface/-/graphql-language-service-interface-2.10.2.tgz#de9386f699e446320256175e215cdc10ccf9f9b7" + integrity sha512-RKIEBPhRMWdXY3fxRs99XysTDnEgAvNbu8ov/5iOlnkZsWQNzitjtd0O0l1CutQOQt3iXoHde7w8uhCnKL4tcg== + dependencies: + graphql-config "^4.1.0" + graphql-language-service-parser "^1.10.4" + graphql-language-service-types "^1.8.7" + graphql-language-service-utils "^2.7.1" vscode-languageserver-types "^3.15.1" -graphql-language-service-parser@^1.9.0: - version "1.9.2" - resolved "https://registry.yarnpkg.com/graphql-language-service-parser/-/graphql-language-service-parser-1.9.2.tgz#b2dc45620cb6b9bac8ac175c197c77f0ff12d679" - integrity sha512-3txms73cJsXDfJQdR5hI83N2rpTuq9FD6aijdrXAeSuI5B60g32DxjelUkt4Ge+2BvBEDLn5ppXlpVYDC9UQHQ== +graphql-language-service-parser@^1.10.3, graphql-language-service-parser@^1.10.4: + version "1.10.4" + resolved "https://registry.yarnpkg.com/graphql-language-service-parser/-/graphql-language-service-parser-1.10.4.tgz#b2979deefc5c0df571dacd409b2d5fbf1cdf7a9d" + integrity sha512-duDE+0aeKLFVrb9Kf28U84ZEHhHcvTjWIT6dJbIAQJWBaDoht0D4BK9EIhd94I3DtKRc1JCJb2+70y1lvP/hiA== dependencies: - graphql-language-service-types "^1.8.0" + graphql-language-service-types "^1.8.7" -graphql-language-service-types@^1.8.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/graphql-language-service-types/-/graphql-language-service-types-1.8.2.tgz#50ae56f69cc24fcfc3daa129b68b0eb9421e8578" - integrity sha512-Sj07RHnMwAhEvAt7Jdt1l/x56ZpoNh+V6g+T58CF6GiYqI5l4vXqqRB4d4xHDcNQX98GpJfnf3o8BqPgP3C5Sw== - -graphql-language-service-utils@^2.5.1: - version "2.5.3" - resolved "https://registry.yarnpkg.com/graphql-language-service-utils/-/graphql-language-service-utils-2.5.3.tgz#185f4f65cf8c010871eb9405452a3a0bfdf88748" - integrity sha512-ydevEZ0AgzEKQF3hiCbLXuS0o7189Ww/T30WtCKCLaRHDYk9Yyb2PZWdhSTWLxYZTaX2TccV6NtFWvzIC7UP3g== +graphql-language-service-types@^1.8.6, graphql-language-service-types@^1.8.7: + version "1.8.7" + resolved "https://registry.yarnpkg.com/graphql-language-service-types/-/graphql-language-service-types-1.8.7.tgz#f5e909e6d9334ea2d8d1f7281b695b6f5602c07f" + integrity sha512-LP/Mx0nFBshYEyD0Ny6EVGfacJAGVx+qXtlJP4hLzUdBNOGimfDNtMVIdZANBXHXcM41MDgMHTnyEx2g6/Ttbw== dependencies: - graphql-language-service-types "^1.8.0" + graphql-config "^4.1.0" + vscode-languageserver-types "^3.15.1" + +graphql-language-service-utils@^2.6.3, graphql-language-service-utils@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/graphql-language-service-utils/-/graphql-language-service-utils-2.7.1.tgz#c97c8d744a761480aba7e03e4a42adf28b6fce39" + integrity sha512-Wci5MbrQj+6d7rfvbORrA9uDlfMysBWYaG49ST5TKylNaXYFf3ixFOa74iM1KtM9eidosUbI3E1JlWi0JaidJA== + dependencies: + "@types/json-schema" "7.0.9" + graphql-language-service-types "^1.8.7" nullthrows "^1.0.0" graphql-language-service@^3.1.2: - version "3.1.4" - resolved "https://registry.yarnpkg.com/graphql-language-service/-/graphql-language-service-3.1.4.tgz#ca8698f70e9923e3267e3d457228bc55a7dd75f9" - integrity sha512-AF98AT4wLxkE9q1gRf20Yn0EPgd5SctRiw1IkGFivPr98pEX0sKqUcIcIHePn2mxqf73jlWUJV5v6l/CB1gdqQ== + version "3.2.5" + resolved "https://registry.yarnpkg.com/graphql-language-service/-/graphql-language-service-3.2.5.tgz#aa73884fced898e8efeaa5a13188e00a9c1b4552" + integrity sha512-utkQ8GfYrR310E7AWk2dGE9QRidIEtAJPJ5j0THHlA+h12s4loZmmGosaHpjzbKy6WCNKNw8aKkqt3eEBxJJRg== dependencies: - graphql-language-service-interface "^2.8.2" - graphql-language-service-types "^1.8.0" + graphql-language-service-interface "^2.9.5" + graphql-language-service-parser "^1.10.3" + graphql-language-service-types "^1.8.6" + graphql-language-service-utils "^2.6.3" + +graphql-language-service@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/graphql-language-service/-/graphql-language-service-5.0.6.tgz#7fd1e6479e5c3074b070c760fa961d9ad1ed7c72" + integrity sha512-FjE23aTy45Lr5metxCv3ZgSKEZOzN7ERR+OFC1isV5mHxI0Ob8XxayLTYjQKrs8b3kOpvgTYmSmu6AyXOzYslg== + dependencies: + nullthrows "^1.0.0" + vscode-languageserver-types "^3.15.1" graphql-ws@^4.3.2: version "4.9.0" resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-4.9.0.tgz#5cfd8bb490b35e86583d8322f5d5d099c26e365c" integrity sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag== +graphql-ws@^5.4.1: + version "5.11.2" + resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.11.2.tgz#d5e0acae8b4d4a4cf7be410a24135cfcefd7ddc0" + integrity sha512-4EiZ3/UXYcjm+xFGP544/yW1+DVI8ZpKASFbzrV5EDTFWJp0ZvLl4Dy2fSZAzz9imKp5pZMIcjB0x/H69Pv/6w== + "graphql@>= v14.5.0 <= 15.5.0": version "15.5.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= - dependencies: - ansi-regex "^2.0.0" - has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== +has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -1657,46 +1835,20 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -he@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +htmx.org@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3" + integrity sha512-xdR2PeSmhftFhUKn/5DYDFRVF8DagJR9d7y3AK+gQzoAQ+08r+0shaCTo1HdXKGKhRfX/uL3rqj4ZwCBNf8tLw== -highlight-es@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/highlight-es/-/highlight-es-1.0.3.tgz#12abc300a27e686f6f18010134e3a5c6d2fe6930" - integrity sha512-s/SIX6yp/5S1p8aC/NRDC1fwEb+myGIfp8/TzZz0rtAv8fzsdX7vGl3Q1TrXCsczFq8DI3CBFBCySPClfBSdbg== - dependencies: - chalk "^2.4.0" - is-es2016-keyword "^1.0.0" - js-tokens "^3.0.0" +ignore@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" + integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== -homedir-polyfill@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" - integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== - dependencies: - parse-passwd "^1.0.0" - -hosted-git-info@^2.1.4, hosted-git-info@^2.8.9: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== - -htmx.org@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.6.1.tgz#6f0d59a93fa61cbaa15316c134a2f179045a5778" - integrity sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA== - -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - -ignore@^5.1.4: - version "5.1.8" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" - integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +immutable@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef" + integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ== import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" @@ -1706,23 +1858,11 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= - dependencies: - repeating "^2.0.0" - inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -1736,30 +1876,6 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@^1.3.4, ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -inquirer@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" - integrity sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34= - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^2.0.0" - figures "^1.3.5" - lodash "^4.3.0" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - string-width "^1.0.1" - strip-ansi "^3.0.0" - through "^2.3.6" - internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -1772,7 +1888,7 @@ internal-slot@^1.0.3: is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== is-bigint@^1.0.1: version "1.0.4" @@ -1796,19 +1912,24 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-callable@^1.1.4, is-callable@^1.2.3: +is-callable@^1.1.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== -is-ci@^1.0.10, is-ci@^1.0.8: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" - integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== - dependencies: - ci-info "^1.5.0" +is-callable@^1.2.6: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.2.0, is-core-module@^2.6.0: +is-core-module@^2.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" + integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== + dependencies: + has "^1.0.3" + +is-core-module@^2.2.0: version "2.6.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19" integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ== @@ -1822,38 +1943,16 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" -is-es2016-keyword@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-es2016-keyword/-/is-es2016-keyword-1.0.0.tgz#f6e54e110c5e4f8d265e69d2ed0eaf8cf5f47718" - integrity sha1-9uVOEQxeT40mXmnS7Q6vjPX0dxg= +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-finite@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" - integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" @@ -1861,23 +1960,17 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" -is-installed-globally@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" - integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== dependencies: - global-dirs "^0.1.0" - is-path-inside "^1.0.0" + is-extglob "^2.1.1" -is-negative-zero@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" - integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== - -is-npm@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" - integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ= +is-negative-zero@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" + integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== is-number-object@^1.0.4: version "1.0.6" @@ -1891,24 +1984,7 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= - -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= - dependencies: - path-is-inside "^1.0.1" - -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" - integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= - -is-regex@^1.1.3: +is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -1916,17 +1992,14 @@ is-regex@^1.1.3: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-retry-allowed@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" - integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" -is-stream@^1.0.0, is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - -is-string@^1.0.5, is-string@^1.0.6: +is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== @@ -1940,64 +2013,62 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-utf8@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" - integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= - -is-windows@^1.0.1: +is-weakref@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isomorphic-ws@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf" + integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw== + iterall@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== -js-tokens@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" - integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= +js-sdsl@^4.1.4: + version "4.1.5" + resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a" + integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.4.2: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: - argparse "^1.0.7" - esprima "^4.0.0" + argparse "^2.0.1" -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -json-parse-better-errors@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -2010,17 +2081,10 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -just-debounce-it@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/just-debounce-it/-/just-debounce-it-1.5.0.tgz#2276448332dd5925e825ba3c524a71da707bf628" - integrity sha512-itSWJS5d2DTSCizVJ2Z0Djx/dGmUGfZe7WNfUfVP23+htGcIcPHbEjL4eB8ljojTs/+oYwLexImRRCP0A2WXjA== - -latest-version@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" - integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU= - dependencies: - package-json "^4.0.0" +just-debounce-it@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/just-debounce-it/-/just-debounce-it-3.1.1.tgz#aa07c395d48c394233e4bafdcc49ed188fcf62a5" + integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg== levn@^0.4.1: version "0.4.1" @@ -2030,6 +2094,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + linkify-it@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" @@ -2037,57 +2106,12 @@ linkify-it@^2.0.0: dependencies: uc.micro "^1.0.1" -load-json-file@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" - integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - pinkie-promise "^2.0.0" - strip-bom "^2.0.0" - -load-json-file@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" - integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= - dependencies: - graceful-fs "^4.1.2" - parse-json "^4.0.0" - pify "^3.0.0" - strip-bom "^3.0.0" - -load-yaml-file@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/load-yaml-file/-/load-yaml-file-0.1.1.tgz#dc9b8e89cee96757f6f15a5707ac53f76aa529e9" - integrity sha512-G910TofXH7u0NfslAzqv6c9pHpvBzObNOo2hMG0/KUDpdHeFY0wE/fTBMExt0Gb12gg5bXS7Hj6pb0e+xbBXLA== - dependencies: - graceful-fs "^4.1.5" - js-yaml "^3.13.0" - pify "^2.3.0" - strip-bom "^3.0.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -lodash.clonedeep@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" - integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + p-locate "^5.0.0" lodash.debounce@^4.0.8: version "4.0.8" @@ -2109,16 +2133,6 @@ lodash.throttle@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= -lodash.truncate@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" - integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= - -"lodash@4.6.1 || ^4.16.1", lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.3.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - loose-envify@^1.1.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -2126,27 +2140,6 @@ loose-envify@^1.1.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -loud-rejection@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" - integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8= - dependencies: - currently-unhandled "^0.4.1" - signal-exit "^3.0.0" - -lowercase-keys@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lru-cache@^4.0.0, lru-cache@^4.0.1: - version "4.1.5" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -2154,17 +2147,10 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - -map-obj@^1.0.0, map-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== markdown-it@^10.0.0: version "10.0.0" @@ -2190,23 +2176,7 @@ mdurl@^1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= -meow@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" - integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= - dependencies: - camelcase-keys "^2.0.0" - decamelize "^1.1.2" - loud-rejection "^1.0.0" - map-obj "^1.0.1" - minimist "^1.1.3" - normalize-package-data "^2.3.4" - object-assign "^4.0.1" - read-pkg-up "^1.0.1" - redent "^1.0.0" - trim-newlines "^1.0.0" - -merge2@^1.3.0: +merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -2224,18 +2194,37 @@ micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" -"minimatch@2 || 3", minimatch@^3.0.2, minimatch@^3.0.4: +minimatch@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" + integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" -minimist@^1.1.3, minimist@^1.2.0: +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -2251,107 +2240,51 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA= - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -node-emoji@^1.0.3: - version "1.11.0" - resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" - integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== - dependencies: - lodash "^4.17.21" +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-sass-tilde-importer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/node-sass-tilde-importer/-/node-sass-tilde-importer-1.0.2.tgz#1a15105c153f648323b4347693fdb0f331bad1ce" - integrity sha512-Swcmr38Y7uB78itQeBm3mThjxBy9/Ah/ykPIaURY/L6Nec9AyRoL/jJ7ECfMR+oZeCTVQNxVMu/aHU+TLRVbdg== +node-fetch@^2.6.1, node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: - find-parent-dir "^0.3.0" + whatwg-url "^5.0.0" -normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w== dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" + remove-trailing-separator "^1.0.1" normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-check@^5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/npm-check/-/npm-check-5.9.2.tgz#3b8a6230a3f8c11db113a9735b19b1ceac157dbb" - integrity sha512-YlTZGP1A8+Rad5wldGil9STYxgZpZl18X6GZI03f4Ch6qTI1TLHIYM0ISco19qgg8M3UHfooEqMfYOpOkF3AeA== - dependencies: - babel-runtime "^6.6.1" - callsite-record "^3.0.0" - chalk "^1.1.3" - co "^4.6.0" - depcheck "0.8.3" - execa "^0.2.2" - giturl "^1.0.0" - global-modules "^1.0.0" - globby "^4.0.0" - inquirer "^0.12.0" - is-ci "^1.0.8" - lodash "^4.17.15" - meow "^3.7.0" - minimatch "^3.0.2" - node-emoji "^1.0.3" - ora "^0.2.1" - package-json "^4.0.1" - path-exists "^2.1.0" - pkg-dir "^1.0.0" - preferred-pm "^1.0.1" - semver "^5.0.1" - semver-diff "^2.0.0" - text-table "^0.2.0" - throat "^2.0.2" - update-notifier "^2.1.0" - xtend "^4.0.1" - -npm-run-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-1.0.0.tgz#f5c32bf595fe81ae927daec52e82f8b000ac3c8f" - integrity sha1-9cMr9ZX+ga6Sfa7FLoL4sACsPI8= - dependencies: - path-key "^1.0.0" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - nullthrows@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - -object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.11.0, object-inspect@^1.9.0: +object-inspect@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== + +object-inspect@^1.9.0: version "1.11.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1" integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg== @@ -2361,24 +2294,24 @@ object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" - integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== +object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - has-symbols "^1.0.1" + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" object-keys "^1.1.1" -object.values@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30" - integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg== +object.values@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" + integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== dependencies: call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.2" + es-abstract "^1.19.1" once@^1.3.0: version "1.4.0" @@ -2387,10 +2320,14 @@ once@^1.3.0: dependencies: wrappy "1" -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= +open@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" + integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" optionator@^0.9.1: version "0.9.1" @@ -2404,16 +2341,6 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ora@^0.2.1: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" - integrity sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q= - dependencies: - chalk "^1.1.1" - cli-cursor "^1.0.2" - cli-spinners "^0.1.2" - object-assign "^4.0.1" - outlayer@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/outlayer/-/outlayer-2.1.1.tgz#29863b6de10ea5dadfffcadfa0d728907387e9a2" @@ -2423,58 +2350,19 @@ outlayer@^2.1.0: fizzy-ui-utils "^2.0.0" get-size "^2.0.2" -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== +p-limit@3.1.0, p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== dependencies: - p-try "^1.0.0" + yocto-queue "^0.1.0" -p-limit@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== dependencies: - p-try "^2.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -package-json@^4.0.0, package-json@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed" - integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0= - dependencies: - got "^6.7.1" - registry-auth-token "^3.0.1" - registry-url "^3.0.3" - semver "^5.1.0" + p-limit "^3.0.2" parent-module@^1.0.0: version "1.0.1" @@ -2483,58 +2371,26 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== dependencies: + "@babel/code-frame" "^7.0.0" error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" -parse-passwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" - integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= - -path-exists@^2.0.0, path-exists@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" - integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s= - dependencies: - pinkie-promise "^2.0.0" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= - -path-key@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-1.0.0.tgz#5d53d578019646c0d68800db4e146e6bdc2ac7af" - integrity sha1-XVPVeAGWRsDWiADbThRua9wqx68= - -path-key@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -2545,100 +2401,26 @@ path-parse@^1.0.6, path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-type@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" - integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= - dependencies: - graceful-fs "^4.1.2" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -path-type@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" - integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== - dependencies: - pify "^3.0.0" - path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.0: +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: version "2.3.0" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== -pify@^2.0.0, pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= - -pkg-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" - integrity sha1-ektQio1bstYp1EcFb/TpyTFM89Q= - dependencies: - find-up "^1.0.0" - -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" - -pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" - integrity sha1-yBmscoBZpGHKscOImivjxJoATX8= - dependencies: - find-up "^2.1.0" - -please-upgrade-node@^3.1.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" - integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== - dependencies: - semver-compare "^1.0.0" - -preferred-pm@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/preferred-pm/-/preferred-pm-1.0.1.tgz#539df37ce944b1b765ae944a8ba34a7e68694e8d" - integrity sha512-9Uxgin5Xnsl67DBvlNFsmDIlBuG9/XKK2cVBTj//7/7wW6ZY+IC9/GlLqxyHABpoasAsJ1MARFOdYPxMUtndxA== - dependencies: - path-exists "^3.0.0" - which-pm "^1.0.1" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prepend-http@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= - prettier-linter-helpers@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" @@ -2646,30 +2428,32 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d" - integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ== - -progress@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= +prettier@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -query-string@^6.14.1: - version "6.14.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.14.1.tgz#7ac2dca46da7f309449ba0f86b1fd28255b0c86a" - integrity sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw== +pvtsutils@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" + integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== + dependencies: + tslib "^2.4.0" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + +query-string@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" + integrity sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w== dependencies: decode-uri-component "^0.2.0" filter-obj "^1.1.0" @@ -2681,16 +2465,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -rc@^1.0.1, rc@^1.1.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - react-dom@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" @@ -2708,40 +2482,6 @@ react@17.0.2: loose-envify "^1.1.0" object-assign "^4.1.1" -read-pkg-up@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" - integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= - dependencies: - find-up "^1.0.0" - read-pkg "^1.0.0" - -read-pkg-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" - integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc= - dependencies: - find-up "^2.0.0" - read-pkg "^3.0.0" - -read-pkg@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" - integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= - dependencies: - load-json-file "^1.0.0" - normalize-package-data "^2.3.2" - path-type "^1.0.0" - -read-pkg@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" - integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= - dependencies: - load-json-file "^4.0.0" - normalize-package-data "^2.3.2" - path-type "^3.0.0" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -2749,89 +2489,36 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - integrity sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU= +regexp.prototype.flags@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" + integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" + call-bind "^1.0.2" + define-properties "^1.1.3" + functions-have-names "^1.2.2" -redent@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" - integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94= - dependencies: - indent-string "^2.1.0" - strip-indent "^1.0.1" - -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - -regexpp@^3.1.0: +regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -registry-auth-token@^3.0.1: - version "3.4.0" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e" - integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A== - dependencies: - rc "^1.1.6" - safe-buffer "^5.0.1" +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw== -registry-url@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" - integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI= - dependencies: - rc "^1.0.1" - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -require-package-name@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/require-package-name/-/require-package-name-2.0.1.tgz#c11e97276b65b8e2923f75dabf5fb2ef0c3841b9" - integrity sha1-wR6XJ2tluOKSP3Xav1+y7ww4Qbk= - -resolve-dir@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" - integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M= - dependencies: - expand-tilde "^2.0.0" - global-modules "^1.0.0" +resolve-from@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.10.0, resolve@^1.17.0, resolve@^1.20.0: +resolve@^1.20.0: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -2839,13 +2526,14 @@ resolve@^1.10.0, resolve@^1.17.0, resolve@^1.20.0: is-core-module "^2.2.0" path-parse "^1.0.6" -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE= +resolve@^1.22.0, resolve@^1.22.1: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" reusify@^1.0.4: version "1.0.4" @@ -2859,13 +2547,6 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - integrity sha1-yK1KXhEGYeQCp9IbUw4AnyX444k= - dependencies: - once "^1.3.0" - run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -2873,22 +2554,23 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - integrity sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI= +safe-regex-test@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" + integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + is-regex "^1.1.4" -safe-buffer@^5.0.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -sass@^1.32.8, sass@^1.37.5: - version "1.38.2" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.38.2.tgz#970045d9966180002a8c8f3820fc114cddb42822" - integrity sha512-Bz1fG6qiyF0FX6m/I+VxtdVKz1Dfmg/e9kfDy2PhWOkq3T384q2KxwIfP0fXpeI+EyyETdOauH+cRHQDFASllA== +sass@^1.49.0, sass@^1.55.0: + version "1.55.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.55.0.tgz#0c4d3c293cfe8f8a2e8d3b666e1cf1bff8065d1c" + integrity sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A== dependencies: chokidar ">=3.0.0 <4.0.0" + immutable "^4.0.0" + source-map-js ">=0.6.2 <2.0.0" scheduler@^0.20.2: version "0.20.2" @@ -2903,42 +2585,13 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= -semver-compare@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" - integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w= - -semver-diff@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" - integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY= - dependencies: - semver "^5.0.3" - -"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.0.3, semver@^5.1.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@^7.2.1, semver@^7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== +semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -2946,11 +2599,6 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" @@ -2965,15 +2613,10 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -signal-exit@^3.0.0, signal-exit@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" - integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== - -simplebar@^5.3.4: - version "5.3.5" - resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.3.5.tgz#799d14cdc8bb8ed245789745b9f3741d05403944" - integrity sha512-mcTlXEiva8pSMdNEzeV3C1KyBHk7Sn2Pe46U1Uwo53dCuQqdJIK0bEJrIPs8W4/RqJquKIsC8Y1h7+aqOl8ccA== +simplebar@^5.3.9: + version "5.3.9" + resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.3.9.tgz#168ea0eb6d52f29f03960e40d9b69a1b28cf6318" + integrity sha512-1vIIpjDvY9sVH14e0LGeiCiTFU3ILqAghzO6OI9axeG+mvU/vMSrvXeAXkBolqFFz3XYaY8n5ahH9MeP3sp2Ag== dependencies: "@juggle/resize-observer" "^3.3.1" can-use-dom "^0.1.0" @@ -2987,52 +2630,20 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slice-ansi@^4.0.0: +slash@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" + integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== -slim-select@^1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/slim-select/-/slim-select-1.27.0.tgz#0d669c2d2464e69421e158782dc3d310479d976b" - integrity sha512-l2uCHFxoY2iPacwcViTiW5jsQqv4MDrW8/dWlX3+K46eCqYhm1SKJMxUmxRzBz1KKaKC7JcwPabwyYVevFuItA== - dependencies: - npm-check "^5.9.2" +slim-select@^1.27.1: + version "1.27.1" + resolved "https://registry.yarnpkg.com/slim-select/-/slim-select-1.27.1.tgz#aab08c1f0428fc2c32b0376857f3da9b1fbdcb2f" + integrity sha512-LvJ02cKKk6/jSHIcQv7dZwkQSXHLCVQR3v3lo8RJUssUUcmKPkpBmTpQ8au8KSMkxwca9+yeg+dO0iHAaVr5Aw== -source-map@^0.5.0: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -spdx-correct@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" - integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.10" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b" - integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA== +"source-map-js@>=0.6.2 <2.0.0": + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== split-on-first@^1.0.0: version "1.1.0" @@ -3042,131 +2653,58 @@ split-on-first@^1.0.0: sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stackframe@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-0.3.1.tgz#33aa84f1177a5548c8935533cbfeb3420975f5a4" - integrity sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ= +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" +string-env-interpolation@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152" + integrity sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg== -string-width@^2.0.0, string-width@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string-width@^3.0.0, string-width@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string-width@^4.2.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" - integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.0" - -string.prototype.trimend@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" - integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== +string.prototype.trimend@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" + integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" + define-properties "^1.1.4" + es-abstract "^1.19.5" -string.prototype.trimstart@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" - integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== +string.prototype.trimstart@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef" + integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" + define-properties "^1.1.4" + es-abstract "^1.19.5" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= - dependencies: - ansi-regex "^3.0.0" - -strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-ansi@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" - integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== - dependencies: - ansi-regex "^5.0.0" - -strip-bom@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" - integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= - dependencies: - is-utf8 "^0.2.0" + ansi-regex "^5.0.1" strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -strip-indent@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" - integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI= - dependencies: - get-stdin "^4.0.1" - strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - subscriptions-transport-ws@0.9.18: version "0.9.18" resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz#bcf02320c911fbadb054f7f928e51c6041a37b97" @@ -3189,11 +2727,6 @@ subscriptions-transport-ws@^0.9.18: symbol-observable "^1.0.4" ws "^5.2.0 || ^6.0.0 || ^7.0.0" -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= - supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -3208,59 +2741,46 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + symbol-observable@^1.0.4: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== -table@^6.0.9: - version "6.7.1" - resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" - integrity sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg== +synckit@^0.8.3: + version "0.8.4" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.4.tgz#0e6b392b73fafdafcde56692e3352500261d64ec" + integrity sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw== dependencies: - ajv "^8.0.1" - lodash.clonedeep "^4.5.0" - lodash.truncate "^4.4.2" - slice-ansi "^4.0.0" - string-width "^4.2.0" - strip-ansi "^6.0.0" + "@pkgr/utils" "^2.3.1" + tslib "^2.4.0" -term-size@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" - integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk= - dependencies: - execa "^0.7.0" +tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= -throat@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/throat/-/throat-2.0.2.tgz#a9fce808b69e133a632590780f342c30a6249b02" - integrity sha1-qfzoCLaeEzpjJZB4DzQsMKYkmwI= - -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - -timed-out@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" - integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= - tiny-emitter@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= +tiny-glob@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" + integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== + dependencies: + globalyzer "0.1.0" + globrex "^0.1.2" to-regex-range@^5.0.1: version "5.0.1" @@ -3274,19 +2794,38 @@ toggle-selection@^1.0.6: resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= -trim-newlines@^1.0.0, trim-newlines@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" - integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -tsconfig-paths@^3.11.0, tsconfig-paths@^3.9.0: - version "3.11.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz#954c1fe973da6339c78e06b03ce2e48810b65f36" - integrity sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA== +ts-node@^10.8.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tsconfig-paths@^3.14.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" + integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== dependencies: "@types/json5" "^0.0.29" json5 "^1.0.1" - minimist "^1.2.0" + minimist "^1.2.6" strip-bom "^3.0.0" tslib@^1.8.1: @@ -3294,6 +2833,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0, tslib@^2.4.0, tslib@~2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -3313,53 +2857,39 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -typescript@~4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" - integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== +typescript@~4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== -unbox-primitive@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" - integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== dependencies: - function-bind "^1.1.1" - has-bigints "^1.0.1" - has-symbols "^1.0.2" + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -unique-string@^1.0.0: +undici@^5.10.0: + version "5.11.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.11.0.tgz#1db25f285821828fc09d3804b9e2e934ae86fc13" + integrity sha512-oWjWJHzFet0Ow4YZBkyiJwiK5vWqEYoH7BINzJAJOLedZ++JpAlCbUktW2GQ2DS2FpKmxD/JMtWUUWl1BtghGw== + dependencies: + busboy "^1.6.0" + +unixify@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" - integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo= + resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" + integrity sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg== dependencies: - crypto-random-string "^1.0.0" - -unzip-response@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" - integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= - -update-notifier@^2.1.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" - integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw== - dependencies: - boxen "^1.2.1" - chalk "^2.0.1" - configstore "^3.0.0" - import-lazy "^2.1.0" - is-ci "^1.0.10" - is-installed-globally "^0.1.0" - is-npm "^1.0.0" - latest-version "^3.0.0" - semver-diff "^2.0.0" - xdg-basedir "^3.0.0" + normalize-path "^2.1.1" uri-js@^4.2.2: version "4.4.1" @@ -3368,49 +2898,60 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -url-parse-lax@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" - integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= - dependencies: - prepend-http "^1.0.1" +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" +value-or-promise@1.0.11, value-or-promise@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.11.tgz#3e90299af31dd014fe843fe309cefa7c1d94b140" + integrity sha512-41BrgH+dIbCFXClcSapVs5M6GkENd3gQOJpEfPDNa71LsUGMXDL0jMWpI/Rh7WhX+Aalfz2TTS3Zt5pUsbnhLg== vscode-languageserver-types@^3.15.1: - version "3.16.0" - resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz#ecf393fc121ec6974b2da3efb3155644c514e247" - integrity sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA== + version "3.17.2" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz#b2c2e7de405ad3d73a883e91989b850170ffc4f2" + integrity sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA== -vue-template-compiler@^2.6.10: - version "2.6.14" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz#a2f0e7d985670d42c9c9ee0d044fed7690f4f763" - integrity sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g== +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + +web-streams-polyfill@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + +webcrypto-core@^1.7.4: + version "1.7.5" + resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.7.5.tgz#c02104c953ca7107557f9c165d194c6316587ca4" + integrity sha512-gaExY2/3EHQlRNNNVSrbG2Cg94Rutl7fAaKILS1w8ZDhGxdFOaw6EbCfHIxPy9vt/xwp5o0VQAx9aySPF6hU1A== dependencies: - de-indent "^1.0.2" - he "^1.1.0" + "@peculiar/asn1-schema" "^2.1.6" + "@peculiar/json-schema" "^1.1.12" + asn1js "^3.0.1" + pvtsutils "^1.3.2" + tslib "^2.4.0" -walkdir@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.3.2.tgz#ac8437a288c295656848ebc19981ebc677a5f590" - integrity sha512-0Twghia4Z5wDGDYWURlhZmI47GvERMCsXIu0QZWVVZyW9ZjpbbZvD9Zy9M6cWiQQRRbAcYajIyKNavaZZDt1Uw== +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== whatwg-fetch@3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA== +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -3422,26 +2963,6 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which-pm@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/which-pm/-/which-pm-1.1.0.tgz#5c0fc3f722f003707dea7b20cd17effd3ad2fc33" - integrity sha512-7GHHJQpALk7BWMD8I+xSILSbHyngvBlfSXlwGpdRFY2voFwVCx+eJAybXTzTnUYmt7zio6B9SEdI81T0fBjxNA== - dependencies: - load-yaml-file "^0.1.0" - path-exists "^3.0.0" - -which@^1.2.14, which@^1.2.8, which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3449,41 +2970,16 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -widest-line@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc" - integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA== - dependencies: - string-width "^2.1.1" - word-wrap@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -write-file-atomic@^2.0.0: - version "2.4.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481" - integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ== - dependencies: - graceful-fs "^4.1.11" - imurmurhash "^0.1.4" - signal-exit "^3.0.2" - ws@^5.2.0: version "5.2.3" resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.3.tgz#05541053414921bc29c63bee14b8b0dd50b07b3d" @@ -3492,55 +2988,31 @@ ws@^5.2.0: async-limiter "~1.0.0" "ws@^5.2.0 || ^6.0.0 || ^7.0.0": - version "7.5.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.4.tgz#56bfa20b167427e138a7795de68d134fe92e21f9" - integrity sha512-zP9z6GXm6zC27YtspwH99T3qTG7bBFv2VIkeHstMLrLlDJuzA7tQ5ls3OJ1hOGGCzTQPniNJoHXIAOS0Jljohg== + version "7.5.9" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" + integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== -xdg-basedir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" - integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= - -xtend@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= +ws@^8.3.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" + integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@^13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" - integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +yaml@^1.10.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yargs@^13.2.2: - version "13.3.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" - integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.2" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index dd0412eac..2835e1ef2 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/circuit.html b/netbox/templates/circuits/circuit.html index a11139032..81ff6e912 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -60,23 +60,17 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/comments.html' %} + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
-
-
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} -
-
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} -
-
{% plugin_full_width_page object %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 0fc18a368..51f911350 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -33,6 +33,10 @@ Account {{ object.account|placeholder }} + + Description + {{ object.description|placeholder }} + Circuits 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 @@ Label {{ 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/consoleport.html b/netbox/templates/dcim/consoleport.html index 39ffbf552..ad4f15c9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -54,80 +54,40 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} - Marked as connected - {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} + Marked as connected + {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + {% endif %}
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 642e758a3..a543cd5ff 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -54,82 +54,40 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} - Marked as connected - {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device - {{ object.connected_endpoint.device|linkify }} -
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} + Marked as connected + {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + - {% endif %} + {% endif %}
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 6cc859749..046600d08 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -7,7 +7,7 @@ {% block content %}
-
+
Device @@ -66,7 +66,7 @@ {% with object.parent_bay.device as parent %} {{ parent|linkify }} / {{ object.parent_bay }} {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) + (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) {% endif %} {% endwith %} {% elif object.rack and object.position %} @@ -90,11 +90,15 @@ Device Type - {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U) + {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U) - Airflow + Description + {{ object.description|placeholder }} + + + Airflow {{ object.get_airflow_display|placeholder }} @@ -153,7 +157,7 @@ {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
-
+
Management
@@ -178,7 +182,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -193,7 +197,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -286,6 +290,22 @@
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} + {% if object.rack and object.position %} +
+
+
+

Front

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

Rear

+ {% 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 bb3ec9d2e..930390a56 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,14 +27,28 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Height (U) - {{ object.u_height }} + {{ object.u_height|floatformat }} 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/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html deleted file mode 100644 index c7fa7918a..000000000 --- a/netbox/templates/dcim/inc/cabletermination.html +++ /dev/null @@ -1,14 +0,0 @@ - - {% if termination.parent_object.provider %} - - - {{ termination.parent_object.provider }} - {{ termination.parent_object }} - - {% else %} - {{ termination.parent_object|linkify }} - {% endif %} - - - {{ termination|linkify }} - diff --git a/netbox/templates/dcim/inc/connection_endpoints.html b/netbox/templates/dcim/inc/connection_endpoints.html new file mode 100644 index 000000000..fb994a492 --- /dev/null +++ b/netbox/templates/dcim/inc/connection_endpoints.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + +
Cable + {{ object.cable|linkify }} + + + +
Path Status + {% if object.path.is_complete and object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
Path Endpoints + {% for endpoint in object.connected_endpoints %} + {% if endpoint.parent_object %} + {{ endpoint.parent_object|linkify }} + + {% endif %} + {{ endpoint|linkify }} + {% if not forloop.last %}
{% endif %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 1216f3e88..887433d7b 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -144,89 +144,7 @@ Marked as Connected
{% elif object.cable %} - - {% if object.connected_endpoint.device %} - - - - {% endif %} - - - - - {% if object.connected_endpoint.device %} - {% with iface=object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% endwith %} - {% elif object.connected_endpoint.circuit %} - {% with ct=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - -
- {% if object.connected_endpoint.enabled %} - Enabled - {% else %} - Disabled - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ iface.device|linkify }}
Name{{ iface|linkify:"name" }}
Type{{ iface.get_type_display }}
LAG{{ iface.lag|linkify|placeholder }}
Description{{ iface.description|placeholder }}
MTU{{ iface.mtu|placeholder }}
MAC Address{{ iface.mac_address|placeholder }}
802.1Q Mode{{ iface.get_mode_display }}
Provider{{ ct.circuit.provider|linkify }}
Circuit{{ ct.circuit|linkify }}
Side{{ ct.term_side }}
Path Status - {% if object.path.is_complete and object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
+ {% include 'dcim/inc/connection_endpoints.html' %} {% elif object.wireless_link %} @@ -238,7 +156,7 @@ - {% with peer_interface=object.connected_endpoint %} + {% with peer_interface=object.link_peers.0 %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f2dac38f2..139ac2eb8 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -62,6 +62,10 @@ + + + + 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 @@ + + + + + + + + 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 584454df8..6387c111d 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -38,11 +38,15 @@ + + + + - {% with utilization=object.connected_endpoint.get_power_draw %} + {% with utilization=object.connected_endpoints.0.get_power_draw %} {% if utilization %} + + + + @@ -104,9 +108,7 @@
-
- Dimensions -
+
Dimensions
Device {{ peer_interface.device|linkify }}Module Type {{ object.module_type|linkify }}
Description{{ object.description|placeholder }}
Serial Number {{ object.serial|placeholder }}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 }}Status {% badge object.get_status_display bg_color=object.get_status_color %}
Description{{ object.description|placeholder }}
Connected Device - {% if object.connected_endpoint %} - {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) + {% if object.connected_endpoints %} + {{ object.connected_endpoints.0.device|linkify }} ({{ object.connected_endpoints.0|linkify:"name" }}) {% else %} {{ ''|placeholder }} {% endif %} @@ -50,7 +54,7 @@
Utilization (Allocated) {{ utilization.allocated }}VA / {{ object.available_power }}VA @@ -100,73 +104,33 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not connected -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as connected
- {% if not object.mark_connected and not object.cable %} - - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} + {% if not object.mark_connected and not object.cable %} + + {% endif %} +
+ {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 26f4a07f8..fb6de8ddb 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -58,69 +58,29 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as Connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - Connect - - {% endif %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as Connected
+ {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + Connect + + {% endif %} +
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
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/powerport.html b/netbox/templates/dcim/powerport.html index c5eccbf14..18814a428 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -58,79 +58,39 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as Connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - - - - {% endif %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as Connected
+ {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51e873ffa..185634e8a 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 }}
@@ -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,36 @@ 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 %} +
Total Weight{{ object.total_weight|floatformat }} Kilograms
@@ -186,6 +212,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..d214bbee8 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,8 +56,17 @@
Unit
+ {% render_field form.mounting_depth %} {% render_field form.desc_units %} +
+
+
Weight
+
+ {% render_field form.weight %} + {% render_field form.weight_unit %} +
+ {% if form.custom_fields %}
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/rearport.html b/netbox/templates/dcim/rearport.html index dc53746db..f3b02f5a2 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -105,16 +105,16 @@ 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/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..a51b2663d 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -2,6 +2,9 @@

Initiated: {{ result.created|annotated_date }} + {% if result.scheduled_time %} + Scheduled for: {{ result.scheduled_time|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 425f35898..d2af99c9b 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,6 +3,9 @@

Initiated: {{ result.created|annotated_date }} + {% if result.scheduled_time %} + Scheduled for: {{ result.scheduled_time|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% 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..94f37571b 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,18 +34,24 @@ {% block content %}

{% if perms.extras.run_report %} -
-
+
+
+ {% csrf_token %} - + {% render_form form %} +
+ +
+
+ {% endif %}
diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index b4a0c0f12..0c61c63f9 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -1,4 +1,6 @@ {% extends 'extras/report.html' %} +{% load buttons %} +{% load perms %} {% block content-wrapper %}
@@ -7,3 +9,13 @@
{% 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..4372481aa --- /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 }}
+
+
+ {% 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..2fc01e9fa 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 %}
{% endblock content-wrapper %} + +{% block modals %} + {% include 'inc/htmx_modal.html' %} +{% endblock modals %} \ No newline at end of file diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index b0b88b5af..6e4c5aee9 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -39,6 +39,7 @@
+ {% plugin_left_page object %}
@@ -64,6 +65,7 @@
+ {% plugin_right_page object %}
diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ef95ccdc0..023726a30 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -4,6 +4,7 @@ {% load helpers %} {% load perms %} {% load plugins %} +{% load tabs %} {% comment %} Blocks: @@ -80,37 +81,14 @@ Context: {% endblock tabs %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 56e4f5a32..c61fb723f 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -37,9 +37,9 @@ Context: {% endif %} {# Link to model documentation #} - {% if object and settings.DOCS_ROOT %} + {% if settings.DOCS_ROOT and object.docs_url %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 60eba6097..c58565c31 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -64,7 +64,7 @@ Context: {# Applied filters #} {% if filter_form %} - {% applied_filters filter_form request.GET %} + {% applied_filters model filter_form request.GET %} {% endif %} {# "Select all" form #} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index a12ec9277..f98d0ccf3 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -36,8 +36,8 @@
{% for item in items %} - {% if not item.disabled %} - + {% if item.permission in perms %} +
{{ item.label }}

{{ item.count }}

diff --git a/netbox/templates/inc/searchbar.html b/netbox/templates/inc/searchbar.html new file mode 100644 index 000000000..c8ef0d548 --- /dev/null +++ b/netbox/templates/inc/searchbar.html @@ -0,0 +1,6 @@ + + + + diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index f3eff9df1..b95341b16 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -51,6 +51,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/aggregate/base.html b/netbox/templates/ipam/aggregate/base.html index c69661a65..968c4a041 100644 --- a/netbox/templates/ipam/aggregate/base.html +++ b/netbox/templates/ipam/aggregate/base.html @@ -6,13 +6,3 @@ {{ block.super }}
{% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_prefix %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 7afe981e6..3af5177cc 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -67,6 +67,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 89fc7083c..a74ddac70 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -42,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 02816b440..bf86e6c41 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -13,7 +13,7 @@ {% render_field form.tags %}
-
+
Authentication
@@ -22,7 +22,7 @@
{% if not form.instance.pk %} -
+
Virtual IP Address
@@ -32,6 +32,13 @@
{% endif %} +
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7f77e8137..4a110c2e6 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 %}
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/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index 4d1747e72..d904deead 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -3,6 +3,9 @@ {% load form_helpers %} {% block form %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
Contact Assignment
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 8acbb61f4..510c5a48e 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -19,9 +19,17 @@ Type {{ object.type|linkify }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + + + Description + {{ object.description|placeholder }} + Group - {{ object.group|linkify }} + {{ object.group|linkify|placeholder }} Tenant @@ -34,7 +42,7 @@ Site - {{ object.site|linkify }} + {{ object.site|linkify|placeholder }} Virtual Machines 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 5756d939a..9d95b02ea 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 @@ -46,7 +50,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -61,7 +65,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} 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/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..137f79d42 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -26,7 +26,7 @@ class TenantGroupCSVForm(NetBoxModelCSVForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class TenantCSVForm(NetBoxModelCSVForm): @@ -40,7 +40,7 @@ class TenantCSVForm(NetBoxModelCSVForm): class Meta: model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments') + fields = ('name', 'slug', 'group', 'description', 'comments', 'tags') # @@ -58,7 +58,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class ContactRoleCSVForm(NetBoxModelCSVForm): @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): 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..f840a2177 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', '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 81% rename from netbox/tenancy/forms/models.py rename to netbox/tenancy/forms/model_forms.py index 021e36a5b..b466c94b2 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/model_forms.py @@ -27,6 +27,12 @@ class TenantGroupForm(NetBoxModelForm): ) slug = SlugField() + fieldsets = ( + ('Tenant Group', ( + 'parent', 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = TenantGroup fields = [ @@ -64,6 +70,12 @@ class ContactGroupForm(NetBoxModelForm): ) slug = SlugField() + fieldsets = ( + ('Contact Group', ( + 'parent', 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = ContactGroup fields = ('parent', 'name', 'slug', 'description', 'tags') @@ -72,6 +84,12 @@ class ContactGroupForm(NetBoxModelForm): class ContactRoleForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Contact Role', ( + 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = ContactRole fields = ('name', 'slug', 'description', 'tags') @@ -85,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}), @@ -119,8 +137,10 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): class Meta: model = ContactAssignment fields = ( - 'group', 'contact', 'role', 'priority', + 'content_type', 'object_id', 'group', 'contact', 'role', 'priority', ) widgets = { + 'content_type': forms.HiddenInput(), + 'object_id': forms.HiddenInput(), 'priority': StaticSelect(), } 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/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 79c0a2db3..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,25 +19,6 @@ 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'] constraints = ( @@ -56,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. """ @@ -111,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', @@ -174,8 +132,8 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): def __str__(self): if self.priority: - return f"{self.contact} ({self.get_priority_display()})" - return str(self.contact) + return f"{self.contact} ({self.get_priority_display()}) -> {self.object}" + return str(f"{self.contact} -> {self.object}") def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) 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..8cb3c4ccb --- /dev/null +++ b/netbox/tenancy/search.py @@ -0,0 +1,57 @@ +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), + ('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/columns.py b/netbox/tenancy/tables/columns.py index 21622f18b..491d0488f 100644 --- a/netbox/tenancy/tables/columns.py +++ b/netbox/tenancy/tables/columns.py @@ -1,6 +1,9 @@ import django_tables2 as tables +from netbox.tables import columns + __all__ = ( + 'ContactsColumnMixin', 'TenantColumn', 'TenantGroupColumn', 'TenancyColumnsMixin', @@ -55,3 +58,10 @@ class TenantGroupColumn(tables.TemplateColumn): class TenancyColumnsMixin(tables.Table): tenant_group = TenantGroupColumn() tenant = TenantColumn() + + +class ContactsColumnMixin(tables.Table): + contacts = columns.ManyToManyColumn( + linkify_item=True, + transform=lambda obj: obj.contact.name + ) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 234dc2ad7..b66a1182f 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -65,8 +65,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/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 8f18423be..9c404fb4b 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -1,7 +1,8 @@ import django_tables2 as tables +from tenancy.models import * +from tenancy.tables import ContactsColumnMixin from netbox.tables import NetBoxTable, columns -from tenancy.models import * __all__ = ( 'TenantGroupTable', @@ -30,7 +31,7 @@ class TenantGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'tenant_count', 'description') -class TenantTable(NetBoxTable): +class TenantTable(ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -38,11 +39,8 @@ class TenantTable(NetBoxTable): linkify=True ) comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( - url_name='tenancy:tenant_list' + url_name='tenancy:contact_list' ) class Meta(NetBoxTable.Meta): 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 e582c15d1..d95568a22 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,5 +1,4 @@ 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 @@ -7,6 +6,7 @@ from dcim.models import Cable, Device, Location, Rack, RackReservation, Site from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN 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,11 +46,13 @@ 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() @@ -95,6 +98,7 @@ class TenantListView(generic.ObjectListView): table = tables.TenantTable +@register_model_view(Tenant) class TenantView(generic.ObjectView): queryset = Tenant.objects.all() @@ -125,11 +129,13 @@ 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() @@ -170,6 +176,7 @@ class ContactGroupListView(generic.ObjectListView): table = tables.ContactGroupTable +@register_model_view(ContactGroup) class ContactGroupView(generic.ObjectView): queryset = ContactGroup.objects.all() @@ -188,6 +195,8 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance + ).annotate( + assignment_count=count_related(ContactAssignment, 'contact') ) contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) @@ -198,11 +207,13 @@ 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() @@ -248,6 +259,7 @@ class ContactRoleListView(generic.ObjectListView): table = tables.ContactRoleTable +@register_model_view(ContactRole) class ContactRoleView(generic.ObjectView): queryset = ContactRole.objects.all() @@ -265,11 +277,13 @@ 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() @@ -305,6 +319,7 @@ class ContactListView(generic.ObjectListView): table = tables.ContactTable +@register_model_view(Contact) class ContactView(generic.ObjectView): queryset = Contact.objects.all() @@ -322,11 +337,13 @@ 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() @@ -338,14 +355,18 @@ class ContactBulkImportView(generic.BulkImportView): class ContactBulkEditView(generic.BulkEditView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable form = forms.ContactBulkEditForm class ContactBulkDeleteView(generic.BulkDeleteView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable @@ -354,6 +375,7 @@ class ContactBulkDeleteView(generic.BulkDeleteView): # Contact assignments # +@register_model_view(ContactAssignment, 'edit') class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() form = forms.ContactAssignmentForm @@ -373,5 +395,6 @@ class ContactAssignmentEditView(generic.ObjectEditView): } +@register_model_view(ContactAssignment, 'delete') class ContactAssignmentDeleteView(generic.ObjectDeleteView): queryset = ContactAssignment.objects.all() 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/forms.py b/netbox/users/forms.py index b4e86461d..048005f13 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,4 +1,5 @@ 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 @@ -117,3 +118,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..441ed2eee 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 @@ -230,12 +231,12 @@ class Token(models.Model): '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: 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/utils.py b/netbox/users/utils.py new file mode 100644 index 000000000..114d8ab6d --- /dev/null +++ b/netbox/users/utils.py @@ -0,0 +1,9 @@ +from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX + + +def clean_username(value): + """Clean username removing any unsupported character""" + value = NO_ASCII_REGEX.sub('', value) + value = NO_SPECIAL_REGEX.sub('', value) + value = value.replace(':', '') + return value diff --git a/netbox/users/views.py b/netbox/users/views.py index 33ef3fadd..fe1181fc1 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -273,6 +273,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 +281,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,6 +296,7 @@ class TokenEditView(LoginRequiredMixin, View): 'object': token, 'form': form, 'return_url': reverse('users:token_list'), + 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL }) 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..59765cae8 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -16,6 +16,7 @@ __all__ = ( 'CSVDataField', 'CSVFileField', 'CSVModelChoiceField', + 'CSVModelMultipleChoiceField', 'CSVMultipleChoiceField', 'CSVMultipleContentTypeField', 'CSVTypedChoiceField', @@ -142,7 +143,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 +155,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/forms.py b/netbox/utilities/forms/forms.py index 44721ee5d..b89e61dda 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -136,6 +136,14 @@ class CSVModelForm(forms.ModelForm): class BaseImportForm(BootstrapMixin, forms.Form): + data_field = 'data' + + # TODO: Enable auto-detection of format + format = forms.ChoiceField( + choices=ImportFormatChoices, + initial=ImportFormatChoices.CSV, + widget=StaticSelect() + ) def __init__(self, *args, **kwargs): related = kwargs.pop("related", False) @@ -144,10 +152,6 @@ class BaseImportForm(BootstrapMixin, forms.Form): self.fields['format'].choices = ImportFormatChoicesRelated.CHOICES self.fields['format'].initial = ImportFormatChoicesRelated.YAML - @property - def data_field(self): - return 'data' - def convert_data(self, data): format = self.cleaned_data['format'] stream = StringIO(data.strip()) @@ -180,21 +184,16 @@ class BaseImportForm(BootstrapMixin, forms.Form): class ImportForm(BaseImportForm): """ - Generic form for creating an object from JSON/YAML data + Generic form for creating an object from CSV/JSON/YAML data """ data = forms.CharField( widget=forms.Textarea(attrs={'class': 'font-monospace'}), help_text="Enter object data in CSV, JSON or YAML format." ) - format = forms.ChoiceField( - choices=ImportFormatChoices.CHOICES, - initial=ImportFormatChoices.CSV - ) def clean(self): super().clean() - - data = self.cleaned_data['data'] if 'data' in self.cleaned_data else None + data = self.cleaned_data.get('data') self.convert_data(data) @@ -206,20 +205,12 @@ class FileUploadImportForm(BaseImportForm): label="data file", required=False ) - format = forms.ChoiceField( - choices=ImportFormatChoices.CHOICES, - initial=ImportFormatChoices.CSV - ) - @property - def data_field(self): - return 'data_file' + data_field = 'data_file' def clean(self): super().clean() - file = self.files.get('data_file') - data = file.read().decode('utf-8') self.convert_data(data) 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/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/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/search/searchbar.html b/netbox/utilities/templates/search/searchbar.html deleted file mode 100644 index 74d12e9b9..000000000 --- a/netbox/utilities/templates/search/searchbar.html +++ /dev/null @@ -1,50 +0,0 @@ -
- - - - - All Objects - - - - - - - -
diff --git a/netbox/utilities/templates/tabs/model_view_tabs.html b/netbox/utilities/templates/tabs/model_view_tabs.html new file mode 100644 index 000000000..e41acf4c0 --- /dev/null +++ b/netbox/utilities/templates/tabs/model_view_tabs.html @@ -0,0 +1,7 @@ +{% for tab in tabs %} + +{% endfor %} diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 6b548a89d..8c9315ffe 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import clean_html, foreground_color +from utilities.utils import clean_html, foreground_color, title register = template.Library() @@ -46,7 +46,7 @@ def bettertitle(value): Alternative to the builtin title(). Ensures that the first letter of each word is uppercase but retains the original case of all others. """ - return ' '.join([w[0].upper() + w[1:] for w in value.split()]) + return title(value) @register.filter() diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 4b8178405..bcdb099d8 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -83,7 +83,7 @@ def export_button(context, model): data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV' # Retrieve all export templates for this model - export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_types=content_type) return { 'perms': context['perms'], diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 67ed553b2..ed2e39041 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,9 +1,11 @@ import datetime import decimal +from urllib.parse import quote from typing import Dict, Any from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -73,9 +75,9 @@ def humanize_megabytes(mb): """ if not mb: return '' - if mb >= 1048576: + if not mb % 1048576: # 1024^2 return f'{int(mb / 1048576)} TB' - if mb >= 1024: + if not mb % 1024: return f'{int(mb / 1024)} GB' return f'{mb} MB' @@ -141,14 +143,6 @@ def percentage(x, y): return round(x / y * 100) -@register.filter() -def get_docs_url(model): - """ - Return the documentation URL for the specified model. - """ - return f'{settings.STATIC_URL}docs/models/{model._meta.app_label}/{model._meta.model_name}/' - - @register.filter() def has_perms(user, permissions_list): """ @@ -286,12 +280,13 @@ def table_config_form(table, table_name=None): } -@register.inclusion_tag('helpers/applied_filters.html') -def applied_filters(form, query_params): +@register.inclusion_tag('helpers/applied_filters.html', takes_context=True) +def applied_filters(context, model, form, query_params): """ Display the active filters for a given filter form. """ - form.is_valid() + user = context['request'].user + form.is_valid() # Ensure cleaned_data has been set applied_filters = [] for filter_name in form.changed_data: @@ -313,6 +308,14 @@ def applied_filters(form, query_params): 'link_text': f'{bound_field.label}: {display_value}', }) + save_link = None + if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET: + content_type = ContentType.objects.get_for_model(model).pk + parameters = context['request'].GET.urlencode() + url = reverse('extras:savedfilter_add') + save_link = f"{url}?content_types={content_type}¶meters={quote(parameters)}" + return { 'applied_filters': applied_filters, + 'save_link': save_link, } diff --git a/netbox/utilities/templatetags/search.py b/netbox/utilities/templatetags/search.py deleted file mode 100644 index 5726ae5d5..000000000 --- a/netbox/utilities/templatetags/search.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Dict -from netbox.forms import SearchForm -from django import template - -register = template.Library() - -search_form = SearchForm() - - -@register.inclusion_tag("search/searchbar.html") -def search_options(request) -> Dict: - """Provide search options to template.""" - return { - 'options': search_form.options, - 'request': request, - } diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py new file mode 100644 index 000000000..70f40d742 --- /dev/null +++ b/netbox/utilities/templatetags/tabs.py @@ -0,0 +1,48 @@ +from django import template +from django.urls import reverse +from django.utils.module_loading import import_string + +from netbox.registry import registry + +register = template.Library() + + +# +# Object detail view tabs +# + +@register.inclusion_tag('tabs/model_view_tabs.html', takes_context=True) +def model_view_tabs(context, instance): + app_label = instance._meta.app_label + model_name = instance._meta.model_name + user = context['request'].user + tabs = [] + + # Retrieve registered views for this model + try: + views = registry['views'][app_label][model_name] + except KeyError: + # No views have been registered for this model + views = [] + + # Compile a list of tabs to be displayed in the UI + for config in views: + view = import_string(config['view']) if type(config['view']) is str else config['view'] + if tab := getattr(view, 'tab', None): + if tab.permission and not user.has_perm(tab.permission): + continue + + if attrs := tab.render(instance): + viewname = f"{app_label}:{model_name}_{config['name']}" + active_tab = context.get('tab') + tabs.append({ + 'name': config['name'], + 'url': reverse(viewname, args=[instance.pk]), + 'label': attrs['label'], + 'badge': attrs['badge'], + 'is_active': active_tab and active_tab == tab, + }) + + return { + 'tabs': tabs, + } diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index b3fa5704f..c0836b71d 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,3 +1,4 @@ +import inspect import json from django.conf import settings @@ -5,7 +6,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings -from graphene.types import Dynamic as GQLDynamic, List as GQLList +from graphene.types import Dynamic as GQLDynamic, List as GQLList, Union as GQLUnion from rest_framework import status from rest_framework.test import APIClient @@ -449,6 +450,12 @@ class APIViewTestCases: if type(field) is GQLDynamic: # Dynamic fields must specify a subselection fields_string += f'{field_name} {{ id }}\n' + elif inspect.isclass(field.type) and issubclass(field.type, GQLUnion): + # Union types dont' have an id or consistent values + continue + elif type(field.type) is GQLList and inspect.isclass(field.type.of_type) and issubclass(field.type.of_type, GQLUnion): + # Union types dont' have an id or consistent values + continue elif type(field.type) is GQLList and field_name != 'choices': # TODO: Come up with something more elegant # Temporary hack to support automated testing of reverse generic relations @@ -467,6 +474,7 @@ class APIViewTestCases: return query @override_settings(LOGIN_REQUIRED=True) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user']) def test_graphql_get_object(self): url = reverse('graphql') field_name = self._get_graphql_base_name() @@ -492,6 +500,7 @@ class APIViewTestCases: self.assertNotIn('errors', data) @override_settings(LOGIN_REQUIRED=True) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user']) def test_graphql_list_objects(self): url = reverse('graphql') field_name = f'{self._get_graphql_base_name()}_list' diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 499a5e2e7..04ceca1e2 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,8 +1,10 @@ +import json + from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist -from django.db.models import ManyToManyField +from django.db.models import ManyToManyField, JSONField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase from netaddr import IPNetwork @@ -132,6 +134,10 @@ class ModelTestCase(TestCase): if type(instance._meta.get_field(key)) is ArrayField: model_dict[key] = ','.join([str(v) for v in value]) + # JSON + if type(instance._meta.get_field(key)) is JSONField and value is not None: + model_dict[key] = json.dumps(value) + return model_dict # diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index abfaa5859..5e1e207cc 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,11 +1,15 @@ +import csv + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from django.db.models import ForeignKey from django.test import override_settings from django.urls import reverse from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange from users.models import ObjectPermission +from utilities.forms.choices import ImportFormatChoices from .base import ModelTestCase from .utils import disable_warnings, post_data @@ -19,6 +23,7 @@ __all__ = ( # UI Tests # + class ModelViewTestCase(ModelTestCase): """ Base TestCase for model views. Subclass to test individual views. @@ -546,6 +551,9 @@ class ViewTestCases: def _get_csv_data(self): return '\n'.join(self.csv_data) + def _get_update_csv_data(self): + return self.csv_update_data, '\n'.join(self.csv_update_data) + def test_bulk_import_objects_without_permission(self): data = { 'data': self._get_csv_data(), @@ -585,6 +593,43 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_update_objects_with_permission(self): + if not hasattr(self, 'csv_update_data'): + raise NotImplementedError("The test must define csv_update_data.") + + initial_count = self._get_queryset().count() + array, csv_data = self._get_update_csv_data() + data = { + 'format': ImportFormatChoices.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)) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(initial_count, self._get_queryset().count()) + + reader = csv.DictReader(array, delimiter=',') + check_data = list(reader) + for line in check_data: + obj = self.model.objects.get(id=line["id"]) + for attr, value in line.items(): + if attr != "id": + field = self.model._meta.get_field(attr) + value = getattr(obj, attr) + # cannot verify FK fields as don't know what name the CSV maps to + if value is not None and not isinstance(field, ForeignKey): + self.assertEqual(value, value) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): initial_count = self._get_queryset().count() diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py new file mode 100644 index 000000000..f344b9b61 --- /dev/null +++ b/netbox/utilities/urls.py @@ -0,0 +1,41 @@ +from django.urls import path +from django.utils.module_loading import import_string +from django.views.generic import View + +from netbox.registry import registry + + +def get_model_urls(app_label, model_name): + """ + Return a list of URL paths for detail views registered to the given model. + + Args: + app_label: App/plugin name + model_name: Model name + """ + paths = [] + + # Retrieve registered views for this model + try: + views = registry['views'][app_label][model_name] + except KeyError: + # No views have been registered for this model + views = [] + + for config in views: + # Import the view class or function + if type(config['view']) is str: + view_ = import_string(config['view']) + else: + view_ = config['view'] + if issubclass(view_, View): + view_ = view_.as_view() + + # Create a path to the view + name = f"{model_name}_{config['name']}" if config['name'] else model_name + url_path = f"{config['path']}/" if config['path'] else '' + paths.append( + path(url_path, view_, name=name, kwargs=config['kwargs']) + ) + + return paths diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 69ab615fc..a5bccfbea 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,6 +1,7 @@ import datetime import decimal import json +import re from decimal import Decimal from itertools import count, groupby @@ -9,16 +10,24 @@ from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict +from django.utils.html import escape from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel -from dcim.choices import CableLengthUnitChoices +from dcim.choices import CableLengthUnitChoices, WeightUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY +def title(value): + """ + Improved implementation of str.title(); retains all existing uppercase letters. + """ + return ' '.join([w[0].upper() + w[1:] for w in str(value).split()]) + + def get_viewname(model, action=None, rest_api=False): """ Return the view name for the given model and action, if valid. @@ -270,6 +279,31 @@ def to_meters(length, unit): raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") +def to_grams(weight, unit): + """ + Convert the given weight to kilograms. + """ + try: + if weight < 0: + raise ValueError("Weight must be a positive number") + except TypeError: + raise TypeError(f"Invalid value '{weight}' for weight (must be a number)") + + valid_units = WeightUnitChoices.values() + if unit not in valid_units: + raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}") + + if unit == WeightUnitChoices.UNIT_KILOGRAM: + return weight * 1000 + if unit == WeightUnitChoices.UNIT_GRAM: + return weight + if unit == WeightUnitChoices.UNIT_POUND: + return weight * Decimal(453.592) + if unit == WeightUnitChoices.UNIT_OUNCE: + return weight * Decimal(28.3495) + raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.") + + def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. @@ -366,13 +400,17 @@ def array_to_string(array): return ', '.join(ret) -def content_type_name(ct): +def content_type_name(ct, include_app=True): """ Return a human-friendly ContentType name (e.g. "DCIM > Site"). """ try: meta = ct.model_class()._meta - return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + app_label = title(meta.app_config.verbose_name) + model_name = title(meta.verbose_name) + if include_app: + return f'{app_label} > {model_name}' + return model_name except AttributeError: # Model no longer exists return f'{ct.app_label} > {ct.model}' @@ -447,3 +485,23 @@ def clean_html(html, schemes): attributes=ALLOWED_ATTRIBUTES, protocols=schemes ) + + +def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'): + """ + Highlight a string within a string and optionally trim the pre/post portions of the original string. + """ + # Split value on highlight string + try: + pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE) + except ValueError: + # Match not found + return escape(value) + + # Trim pre/post sections to length + if trim_pre and len(pre) > trim_pre: + pre = trim_placeholder + pre[-trim_pre:] + if trim_post and len(post) > trim_post: + post = post[:trim_post] + trim_placeholder + + return f'{escape(pre)}{escape(match)}{escape(post)}' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 858e7b491..400f127fc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -3,8 +3,17 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import reverse from django.urls.exceptions import NoReverseMatch +from netbox.registry import registry from .permissions import resolve_permission +__all__ = ( + 'ContentTypePermissionRequiredMixin', + 'GetReturnURLMixin', + 'ObjectPermissionRequiredMixin', + 'ViewTab', + 'register_model_view', +) + # # View Mixins @@ -122,3 +131,78 @@ class GetReturnURLMixin: # If all else fails, return home. Ideally this should never happen. return reverse('home') + + +class ViewTab: + """ + ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for + a particular object. + + Args: + label: Human-friendly text + badge: A static value or callable to display alongside the label (optional). If a callable is used, it must + accept a single argument representing the object being viewed. + permission: The permission required to display the tab (optional). + hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a + badge are always displayed.) + """ + def __init__(self, label, badge=None, permission=None, hide_if_empty=False): + self.label = label + self.badge = badge + self.permission = permission + self.hide_if_empty = hide_if_empty + + def render(self, instance): + """Return the attributes needed to render a tab in HTML.""" + badge_value = self._get_badge_value(instance) + if self.badge and self.hide_if_empty and not badge_value: + return None + return { + 'label': self.label, + 'badge': badge_value, + } + + def _get_badge_value(self, instance): + if not self.badge: + return None + if callable(self.badge): + return self.badge(instance) + return self.badge + + +def register_model_view(model, name='', path=None, kwargs=None): + """ + This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject + additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: + + @netbox_model_view(Site, 'myview', path='my-custom-view') + class MyView(ObjectView): + ... + + This will automatically create a URL path for MyView at `/dcim/sites//my-custom-view/` which can be + resolved using the view name `dcim:site_myview'. + + Args: + model: The Django model class with which this view will be associated. + name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended + to the name of the base view for the model using an underscore. If blank, the model name will be used. + path: The URL path by which the view can be reached (optional). If not provided, `name` will be used. + kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional). + """ + def _wrapper(cls): + app_label = model._meta.app_label + model_name = model._meta.model_name + + if model_name not in registry['views'][app_label]: + registry['views'][app_label][model_name] = [] + + registry['views'][app_label][model_name].append({ + 'name': name, + 'view': cls, + 'path': path or name, + 'kwargs': kwargs or {}, + }) + + return cls + + return _wrapper diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b88bc7712..bb4418b43 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -58,8 +58,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -84,8 +84,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer): model = VirtualMachine fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', - 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 35d6e8266..1b6b110df 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class VirtualizationConfig(AppConfig): name = 'virtualization' + + def ready(self): + from . import search diff --git a/netbox/virtualization/forms/__init__.py b/netbox/virtualization/forms/__init__.py index 00f28b852..3c9dd3651 100644 --- a/netbox/virtualization/forms/__init__.py +++ b/netbox/virtualization/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .object_create import * from .bulk_create import * diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index b2429744b..a94b2da1c 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -84,6 +84,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): 'group_id': '$site_group', } ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -91,11 +95,11 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'status', 'tenant',)), - ('Site', ('region', 'site_group', 'site',)), + (None, ('type', 'group', 'status', 'tenant', 'description')), + ('Site', ('region', 'site_group', 'site')), ) nullable_fields = ( - 'group', 'site', 'comments', 'tenant', + 'group', 'site', 'tenant', 'description', 'comments', ) @@ -153,6 +157,10 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Disk (GB)' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -160,11 +168,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 2d7ee52e2..6fc704ae4 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -21,7 +21,7 @@ class ClusterTypeCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterGroupCSVForm(NetBoxModelCSVForm): @@ -29,7 +29,7 @@ class ClusterGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterGroup - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterCSVForm(NetBoxModelCSVForm): @@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'comments', + 'description', 'comments', 'tags', ) @@ -151,7 +151,7 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vrf', + 'vrf', 'tags' ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 4b8ff6d21..62fa4002e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -90,7 +90,7 @@ class VirtualMachineFilterForm( ): model = VirtualMachine fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), @@ -175,7 +175,7 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id')), ) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/model_forms.py similarity index 94% rename from netbox/virtualization/forms/models.py rename to netbox/virtualization/forms/model_forms.py index 268afb9bb..3f598d061 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/model_forms.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm -from dcim.forms.models import INTERFACE_MODE_HELP_TEXT +from dcim.forms.model_forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm @@ -28,6 +28,12 @@ __all__ = ( class ClusterTypeForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Cluster Type', ( + 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = ClusterType fields = ( @@ -38,6 +44,12 @@ class ClusterTypeForm(NetBoxModelForm): class ClusterGroupForm(NetBoxModelForm): slug = SlugField() + fieldsets = ( + ('Cluster Group', ( + 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = ClusterGroup fields = ( @@ -78,7 +90,7 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'description', 'tags')), ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -86,7 +98,8 @@ class ClusterForm(TenancyForm, NetBoxModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'description', 'comments', + 'tags', ) widgets = { 'status': StaticSelect(), @@ -208,9 +221,10 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + comments = CommentField() fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Virtual Machine', ('name', 'role', 'status', 'description', 'tags')), ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), @@ -222,7 +236,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): model = VirtualMachine fields = [ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', - 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data', ] help_texts = { diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 79457a56e..2e0cc5db1 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,5 +1,5 @@ from utilities.forms import ExpandableNameField -from .models import VMInterfaceForm +from .model_forms import VMInterfaceForm __all__ = ( 'VMInterfaceCreateForm', diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index d00bae2e2..2a7894737 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -1,5 +1,5 @@ import dcim.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 @@ -51,7 +51,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)), ('comments', models.TextField(blank=True)), @@ -65,14 +65,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( @@ -80,14 +80,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( @@ -95,7 +95,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(max_length=64)), @@ -147,7 +147,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)), ('enabled', models.BooleanField(default=True)), ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), diff --git a/netbox/virtualization/migrations/0034_standardize_description_comments.py b/netbox/virtualization/migrations/0034_standardize_description_comments.py new file mode 100644 index 000000000..8517adeca --- /dev/null +++ b/netbox/virtualization/migrations/0034_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0033_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='virtualmachine', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/virtualization/models/__init__.py b/netbox/virtualization/models/__init__.py new file mode 100644 index 000000000..dc1e7eb20 --- /dev/null +++ b/netbox/virtualization/models/__init__.py @@ -0,0 +1,2 @@ +from .clusters import * +from .virtualmachines import * diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py new file mode 100644 index 000000000..e7c1294c2 --- /dev/null +++ b/netbox/virtualization/models/clusters.py @@ -0,0 +1,135 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse + +from dcim.models import Device +from netbox.models import OrganizationalModel, PrimaryModel +from virtualization.choices import * + +__all__ = ( + 'Cluster', + 'ClusterGroup', + 'ClusterType', +) + + +class ClusterType(OrganizationalModel): + """ + A type of Cluster. + """ + def get_absolute_url(self): + return reverse('virtualization:clustertype', args=[self.pk]) + + +class ClusterGroup(OrganizationalModel): + """ + An organizational group of Clusters. + """ + # Generic relations + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster_group' + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + def get_absolute_url(self): + return reverse('virtualization:clustergroup', args=[self.pk]) + + +class Cluster(PrimaryModel): + """ + A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. + """ + name = models.CharField( + max_length=100 + ) + type = models.ForeignKey( + to=ClusterType, + on_delete=models.PROTECT, + related_name='clusters' + ) + group = models.ForeignKey( + to=ClusterGroup, + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + + # Generic relations + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster' + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + clone_fields = ( + 'type', 'group', 'status', 'tenant', 'site', + ) + + class Meta: + ordering = ['name'] + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + 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 [ClusterType, ] + + def get_absolute_url(self): + return reverse('virtualization:cluster', args=[self.pk]) + + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + + # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. + if self.pk and self.site: + nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + if nonsite_devices: + raise ValidationError({ + 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( + nonsite_devices, self.site + ) + }) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models/virtualmachines.py similarity index 68% rename from netbox/virtualization/models.py rename to netbox/virtualization/models/virtualmachines.py index 37fcd68ae..d64289eb2 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -6,204 +6,24 @@ from django.db.models import Q from django.db.models.functions import Lower from django.urls import reverse -from dcim.models import BaseInterface, Device +from dcim.models import BaseInterface from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import NetBoxModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar -from .choices import * +from virtualization.choices import * +from .clusters import Cluster __all__ = ( - 'Cluster', - 'ClusterGroup', - 'ClusterType', 'VirtualMachine', 'VMInterface', ) -# -# Cluster types -# - -class ClusterType(OrganizationalModel): - """ - A type of Cluster. - """ - 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('virtualization:clustertype', args=[self.pk]) - - -# -# Cluster groups -# - -class ClusterGroup(OrganizationalModel): - """ - An organizational group of Clusters. - """ - 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 - vlan_groups = GenericRelation( - to='ipam.VLANGroup', - content_type_field='scope_type', - object_id_field='scope_id', - related_query_name='cluster_group' - ) - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('virtualization:clustergroup', args=[self.pk]) - - -# -# Clusters -# - -class Cluster(NetBoxModel): - """ - A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. - """ - name = models.CharField( - max_length=100 - ) - type = models.ForeignKey( - to=ClusterType, - on_delete=models.PROTECT, - related_name='clusters' - ) - group = models.ForeignKey( - to=ClusterGroup, - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - status = models.CharField( - max_length=50, - choices=ClusterStatusChoices, - default=ClusterStatusChoices.STATUS_ACTIVE - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - comments = models.TextField( - blank=True - ) - - # Generic relations - vlan_groups = GenericRelation( - to='ipam.VLANGroup', - content_type_field='scope_type', - object_id_field='scope_id', - related_query_name='cluster' - ) - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - clone_fields = ( - 'type', 'group', 'status', 'tenant', 'site', - ) - - class Meta: - ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - 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 [ClusterType, ] - - def get_absolute_url(self): - return reverse('virtualization:cluster', args=[self.pk]) - - def get_status_color(self): - return ClusterStatusChoices.colors.get(self.status) - - def clean(self): - super().clean() - - # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. - if self.pk and self.site: - nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() - if nonsite_devices: - raise ValidationError({ - 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( - nonsite_devices, self.site - ) - }) - - -# -# Virtual machines -# - -class VirtualMachine(NetBoxModel, ConfigContextModel): +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -300,9 +120,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): null=True, verbose_name='Disk (GB)' ) - comments = models.TextField( - blank=True - ) # Generic relation contacts = GenericRelation( @@ -350,14 +167,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): }) # Validate site for cluster & device - if self.cluster and self.cluster.site != self.site: + if self.cluster and self.site and self.cluster.site != self.site: raise ValidationError({ - 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' - }) - if self.device and self.device.site != self.site: - raise ValidationError({ - 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).' }) + elif self.cluster: + self.site = self.cluster.site # Validate assigned cluster device if self.device and not self.cluster: @@ -366,7 +181,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): }) if self.device and self.device not in self.cluster.devices.all(): raise ValidationError({ - 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' + 'device': f'The selected device ({self.device}) is not assigned to this cluster ({self.cluster}).' }) # Validate primary IP addresses @@ -403,10 +218,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): return None -# -# Interfaces -# - class VMInterface(NetBoxModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py new file mode 100644 index 000000000..184bf7049 --- /dev/null +++ b/netbox/virtualization/search.py @@ -0,0 +1,49 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class ClusterIndex(SearchIndex): + model = models.Cluster + fields = ( + ('name', 100), + ('comments', 5000), + ) + + +@register_search +class ClusterGroupIndex(SearchIndex): + model = models.ClusterGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class ClusterTypeIndex(SearchIndex): + model = models.ClusterType + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class VirtualMachineIndex(SearchIndex): + model = models.VirtualMachine + fields = ( + ('name', 100), + ('comments', 5000), + ) + + +@register_search +class VMInterfaceIndex(SearchIndex): + model = models.VMInterface + fields = ( + ('name', 100), + ('description', 500), + ) diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index ba0f2d301..a3e67373d 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -1,8 +1,8 @@ import django_tables2 as tables +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin +from virtualization.models import Cluster, ClusterGroup, ClusterType from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin -from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( 'ClusterTable', @@ -32,7 +32,7 @@ class ClusterTypeTable(NetBoxTable): default_columns = ('pk', 'name', 'cluster_count', 'description') -class ClusterGroupTable(NetBoxTable): +class ClusterGroupTable(ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -41,9 +41,6 @@ class ClusterGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Clusters' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='virtualization:clustergroup_list' ) @@ -57,7 +54,7 @@ class ClusterGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'cluster_count', 'description') -class ClusterTable(TenancyColumnsMixin, NetBoxTable): +class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -67,6 +64,7 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() site = tables.Column( linkify=True ) @@ -81,9 +79,6 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable): verbose_name='VMs' ) comments = columns.MarkdownColumn() - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='virtualization:cluster_list' ) @@ -91,7 +86,7 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', - 'vm_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments', + 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index dfd01696e..b1d44ad02 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -1,10 +1,10 @@ import django_tables2 as tables - from dcim.tables.devices import BaseInterfaceTable -from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenancyColumnsMixin +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from virtualization.models import VirtualMachine, VMInterface +from netbox.tables import NetBoxTable, columns + __all__ = ( 'VirtualMachineTable', 'VirtualMachineVMInterfaceTable', @@ -37,7 +37,7 @@ VMINTERFACE_BUTTONS = """ # Virtual machines # -class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): +class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -67,9 +67,6 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='virtualization:virtualmachine_list' ) @@ -78,8 +75,8 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', - 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index bf0571d3d..f7fa4cb39 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -70,9 +70,10 @@ class VirtualMachineTestCase(TestCase): with self.assertRaises(ValidationError): VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() - # VM with cluster site but no direct site should fail - with self.assertRaises(ValidationError): - VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() + # VM with cluster site but no direct site should have its site set automatically + vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0]) + vm.full_clean() + self.assertEqual(vm.site, sites[0]) def test_vm_name_case_sensitivity(self): vm1 = VirtualMachine( diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index d00ceb5a2..32382ee3b 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -16,11 +16,12 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ClusterGroup.objects.bulk_create([ + cluster_groups = ( ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), - ]) + ) + ClusterGroup.objects.bulk_create(cluster_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -38,6 +39,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Group 6,cluster-group-6,Sixth cluster group", ) + cls.csv_update_data = ( + "id,name,description", + f"{cluster_groups[0].pk},Cluster Group 7,Fourth cluster group7", + f"{cluster_groups[1].pk},Cluster Group 8,Fifth cluster group8", + f"{cluster_groups[2].pk},Cluster Group 9,Sixth cluster group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -49,11 +57,12 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ClusterType.objects.bulk_create([ + cluster_types = ( ClusterType(name='Cluster Type 1', slug='cluster-type-1'), ClusterType(name='Cluster Type 2', slug='cluster-type-2'), ClusterType(name='Cluster Type 3', slug='cluster-type-3'), - ]) + ) + ClusterType.objects.bulk_create(cluster_types) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -71,6 +80,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Type 6,cluster-type-6,Sixth cluster type", ) + cls.csv_update_data = ( + "id,name,description", + f"{cluster_types[0].pk},Cluster Type 7,Fourth cluster type7", + f"{cluster_types[1].pk},Cluster Type 8,Fifth cluster type8", + f"{cluster_types[2].pk},Cluster Type 9,Sixth cluster type9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -100,11 +116,12 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) ClusterType.objects.bulk_create(clustertypes) - Cluster.objects.bulk_create([ + clusters = ( Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), - ]) + ) + Cluster.objects.bulk_create(clusters) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -126,6 +143,13 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Cluster 6,Cluster Type 1,active", ) + cls.csv_update_data = ( + "id,name,comments", + f"{clusters[0].pk},Cluster 7,New comments 7", + f"{clusters[1].pk},Cluster 8,New comments 8", + f"{clusters[2].pk},Cluster 9,New comments 9", + ) + cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, @@ -187,11 +211,12 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): create_test_device('device2', site=sites[1], cluster=clusters[1]), ) - VirtualMachine.objects.bulk_create([ + virtual_machines = ( VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - ]) + ) + VirtualMachine.objects.bulk_create(virtual_machines) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -221,6 +246,13 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Virtual Machine 6,active,Site 1,Cluster 1,", ) + cls.csv_update_data = ( + "id,name,comments", + f"{virtual_machines[0].pk},Virtual Machine 7,New comments 7", + f"{virtual_machines[1].pk},Virtual Machine 8,New comments 8", + f"{virtual_machines[2].pk},Virtual Machine 9,New comments 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'cluster': clusters[1].pk, @@ -327,6 +359,13 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): f"Virtual Machine 2,Interface 6,{vrfs[0].pk}", ) + cls.csv_update_data = ( + f"id,name,description", + f"{interfaces[0].pk},Interface 7,New description 7", + f"{interfaces[1].pk},Interface 8,New description 8", + f"{interfaces[2].pk},Interface 9,New description 9", + ) + cls.bulk_edit_data = { 'enabled': False, 'mtu': 2000, diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index e01dbc059..9e5d5a670 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/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 Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface app_name = 'virtualization' urlpatterns = [ @@ -13,10 +12,7 @@ urlpatterns = [ path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), - path('cluster-types//', views.ClusterTypeView.as_view(), name='clustertype'), - path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), - path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), - path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path('cluster-types//', include(get_model_urls('virtualization', 'clustertype'))), # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), @@ -24,10 +20,7 @@ urlpatterns = [ path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), - path('cluster-groups//', views.ClusterGroupView.as_view(), name='clustergroup'), - path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), - path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), - path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path('cluster-groups//', include(get_model_urls('virtualization', 'clustergroup'))), # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), @@ -35,15 +28,7 @@ urlpatterns = [ path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'), path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), - path('clusters//', views.ClusterView.as_view(), name='cluster'), - path('clusters//devices/', views.ClusterDevicesView.as_view(), name='cluster_devices'), - path('clusters//virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'), - path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), - path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), - path('clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), - path('clusters//journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}), - path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), - path('clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), + path('clusters//', include(get_model_urls('virtualization', 'cluster'))), # Virtual machines path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), @@ -51,13 +36,7 @@ urlpatterns = [ path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), - path('virtual-machines//', views.VirtualMachineView.as_view(), name='virtualmachine'), - path('virtual-machines//interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'), - path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), - path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), - path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), - path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - path('virtual-machines//journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}), + path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))), # VM interfaces path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'), @@ -66,10 +45,7 @@ urlpatterns = [ path('interfaces/edit/', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'), path('interfaces/rename/', views.VMInterfaceBulkRenameView.as_view(), name='vminterface_bulk_rename'), path('interfaces/delete/', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'), - path('interfaces//', views.VMInterfaceView.as_view(), name='vminterface'), - path('interfaces//edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), - path('interfaces//delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}), + path('interfaces//', include(get_model_urls('virtualization', 'vminterface'))), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 611725d62..bda2415a5 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -3,6 +3,7 @@ from django.db import transaction from django.db.models import Prefetch from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.filtersets import DeviceFilterSet from dcim.models import Device @@ -12,6 +13,7 @@ from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.utils import count_related +from utilities.views import ViewTab, register_model_view from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -29,6 +31,7 @@ class ClusterTypeListView(generic.ObjectListView): table = tables.ClusterTypeTable +@register_model_view(ClusterType) class ClusterTypeView(generic.ObjectView): queryset = ClusterType.objects.all() @@ -47,11 +50,13 @@ class ClusterTypeView(generic.ObjectView): } +@register_model_view(ClusterType, 'edit') class ClusterTypeEditView(generic.ObjectEditView): queryset = ClusterType.objects.all() form = forms.ClusterTypeForm +@register_model_view(ClusterType, 'delete') class ClusterTypeDeleteView(generic.ObjectDeleteView): queryset = ClusterType.objects.all() @@ -91,6 +96,7 @@ class ClusterGroupListView(generic.ObjectListView): table = tables.ClusterGroupTable +@register_model_view(ClusterGroup) class ClusterGroupView(generic.ObjectView): queryset = ClusterGroup.objects.all() @@ -109,11 +115,13 @@ class ClusterGroupView(generic.ObjectView): } +@register_model_view(ClusterGroup, 'edit') class ClusterGroupEditView(generic.ObjectEditView): queryset = ClusterGroup.objects.all() form = forms.ClusterGroupForm +@register_model_view(ClusterGroup, 'delete') class ClusterGroupDeleteView(generic.ObjectDeleteView): queryset = ClusterGroup.objects.all() @@ -157,47 +165,52 @@ class ClusterListView(generic.ObjectListView): filterset_form = forms.ClusterFilterForm +@register_model_view(Cluster) class ClusterView(generic.ObjectView): queryset = Cluster.objects.all() +@register_model_view(Cluster, 'virtualmachines', path='virtual-machines') class ClusterVirtualMachinesView(generic.ObjectChildrenView): queryset = Cluster.objects.all() child_model = VirtualMachine table = tables.VirtualMachineTable filterset = filtersets.VirtualMachineFilterSet template_name = 'virtualization/cluster/virtual_machines.html' + tab = ViewTab( + label=_('Virtual Machines'), + badge=lambda obj: obj.virtual_machines.count(), + permission='virtualization.view_virtualmachine' + ) def get_children(self, request, parent): return VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=parent) - def get_extra_context(self, request, instance): - return { - 'active_tab': 'virtual-machines', - } - +@register_model_view(Cluster, 'devices') class ClusterDevicesView(generic.ObjectChildrenView): queryset = Cluster.objects.all() child_model = Device table = DeviceTable filterset = DeviceFilterSet template_name = 'virtualization/cluster/devices.html' + tab = ViewTab( + label=_('Devices'), + badge=lambda obj: obj.devices.count(), + permission='virtualization.view_virtualmachine' + ) def get_children(self, request, parent): return Device.objects.restrict(request.user, 'view').filter(cluster=parent) - def get_extra_context(self, request, instance): - return { - 'active_tab': 'devices', - } - +@register_model_view(Cluster, 'edit') class ClusterEditView(generic.ObjectEditView): queryset = Cluster.objects.all() form = forms.ClusterForm +@register_model_view(Cluster, 'delete') class ClusterDeleteView(generic.ObjectDeleteView): queryset = Cluster.objects.all() @@ -221,6 +234,7 @@ class ClusterBulkDeleteView(generic.BulkDeleteView): table = tables.ClusterTable +@register_model_view(Cluster, 'add_devices', path='devices/add') class ClusterAddDevicesView(generic.ObjectEditView): queryset = Cluster.objects.all() form = forms.ClusterAddDevicesForm @@ -262,6 +276,7 @@ class ClusterAddDevicesView(generic.ObjectEditView): }) +@register_model_view(Cluster, 'remove_devices', path='devices/remove') class ClusterRemoveDevicesView(generic.ObjectEditView): queryset = Cluster.objects.all() form = forms.ClusterRemoveDevicesForm @@ -315,6 +330,7 @@ class VirtualMachineListView(generic.ObjectListView): template_name = 'virtualization/virtualmachine_list.html' +@register_model_view(VirtualMachine) class VirtualMachineView(generic.ObjectView): queryset = VirtualMachine.objects.prefetch_related('tenant__group') @@ -344,12 +360,18 @@ class VirtualMachineView(generic.ObjectView): } +@register_model_view(VirtualMachine, 'interfaces') class VirtualMachineInterfacesView(generic.ObjectChildrenView): queryset = VirtualMachine.objects.all() child_model = VMInterface table = tables.VirtualMachineVMInterfaceTable filterset = filtersets.VMInterfaceFilterSet template_name = 'virtualization/virtualmachine/interfaces.html' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfaces.count(), + permission='virtualization.view_vminterface' + ) def get_children(self, request, parent): return parent.interfaces.restrict(request.user, 'view').prefetch_related( @@ -357,22 +379,24 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): 'tags', ) - def get_extra_context(self, request, instance): - return { - 'active_tab': 'interfaces', - } - +@register_model_view(VirtualMachine, 'configcontext', path='config-context') class VirtualMachineConfigContextView(ObjectConfigContextView): queryset = VirtualMachine.objects.annotate_config_context_data() base_template = 'virtualization/virtualmachine.html' + tab = ViewTab( + label=_('Config Context'), + permission='extras.view_configcontext' + ) +@register_model_view(VirtualMachine, 'edit') class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all() form = forms.VirtualMachineForm +@register_model_view(VirtualMachine, 'delete') class VirtualMachineDeleteView(generic.ObjectDeleteView): queryset = VirtualMachine.objects.all() @@ -408,6 +432,7 @@ class VMInterfaceListView(generic.ObjectListView): actions = ('import', 'export', 'bulk_edit', 'bulk_delete') +@register_model_view(VMInterface) class VMInterfaceView(generic.ObjectView): queryset = VMInterface.objects.all() @@ -453,11 +478,13 @@ class VMInterfaceCreateView(generic.ComponentCreateView): model_form = forms.VMInterfaceForm +@register_model_view(VMInterface, 'edit') class VMInterfaceEditView(generic.ObjectEditView): queryset = VMInterface.objects.all() form = forms.VMInterfaceForm +@register_model_view(VMInterface, 'delete') class VMInterfaceDeleteView(generic.ObjectDeleteView): queryset = VMInterface.objects.all() diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index d65511765..cc2c8701c 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -33,6 +33,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + status = ChoiceField(choices=WirelessLANStatusChoices, required=False, allow_blank=True) vlan = NestedVLANSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) @@ -41,8 +42,8 @@ class WirelessLANSerializer(NetBoxModelSerializer): class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', - 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', 'auth_type', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -59,5 +60,5 @@ class WirelessLinkSerializer(NetBoxModelSerializer): model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', - 'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py index 59e47aba5..51dee4188 100644 --- a/netbox/wireless/apps.py +++ b/netbox/wireless/apps.py @@ -5,4 +5,4 @@ class WirelessConfig(AppConfig): name = 'wireless' def ready(self): - import wireless.signals + from . import signals, search diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 135fa1b0c..b1f283620 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -1,3 +1,5 @@ +from django.utils.translation import gettext as _ + from utilities.choices import ChoiceSet @@ -11,6 +13,22 @@ class WirelessRoleChoices(ChoiceSet): ) +class WirelessLANStatusChoices(ChoiceSet): + key = 'WirelessLANS.status' + + STATUS_ACTIVE = 'active' + STATUS_RESERVED = 'reserved' + STATUS_DISABLED = 'disabled' + STATUS_DEPRECATED = 'deprecated' + + CHOICES = [ + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_RESERVED, _('Reserved'), 'cyan'), + (STATUS_DISABLED, _('Disabled'), 'orange'), + (STATUS_DEPRECATED, _('Deprecated'), 'red'), + ] + + class WirelessChannelChoices(ChoiceSet): # 2.4 GHz diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 60c4f935b..6ffb9cb91 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -43,6 +43,9 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): lookup_expr='in', to_field_name='slug' ) + status = django_filters.MultipleChoiceFilter( + choices=WirelessLANStatusChoices + ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all() ) diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py index 62c2ec2d9..3098b356a 100644 --- a/netbox/wireless/forms/__init__.py +++ b/netbox/wireless/forms/__init__.py @@ -1,4 +1,4 @@ -from .models import * +from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 639a1ed1b..7544327a5 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, DynamicModelChoiceField +from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -34,6 +34,10 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + choices=add_blank_choice(WirelessLANStatusChoices), + required=False + ) group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -52,9 +56,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -67,14 +68,22 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLAN fieldsets = ( - (None, ('group', 'ssid', 'vlan', 'tenant', 'description')), + (None, ('group', 'ssid', 'status', 'vlan', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) @@ -92,9 +101,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -107,6 +113,14 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLink fieldsets = ( @@ -114,5 +128,5 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) ) nullable_fields = ( - 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 6a1ca4f36..4d96f60ad 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -25,7 +25,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class WirelessLANCSVForm(NetBoxModelCSVForm): @@ -35,6 +35,10 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Assigned group' ) + status = CSVChoiceField( + choices=WirelessLANStatusChoices, + help_text='Operational status' + ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -60,7 +64,10 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ( + 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', + ) class WirelessLinkCSVForm(NetBoxModelCSVForm): @@ -94,5 +101,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLink fields = ( - 'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 9e8808e17..c3e63687d 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -28,8 +28,8 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('ssid', 'group_id',)), + (None, ('q', 'filter', 'tag')), + ('Attributes', ('ssid', 'group_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) @@ -43,6 +43,11 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): null_option='None', label=_('Group') ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessLANStatusChoices), + widget=StaticSelect() + ) auth_type = forms.ChoiceField( required=False, choices=add_blank_choice(WirelessAuthTypeChoices), @@ -62,7 +67,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('ssid', 'status',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/model_forms.py similarity index 87% rename from netbox/wireless/forms/models.py rename to netbox/wireless/forms/model_forms.py index bcffcf896..e59c36696 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/model_forms.py @@ -2,7 +2,7 @@ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect +from utilities.forms import CommentField, DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * __all__ = ( @@ -19,6 +19,12 @@ class WirelessLANGroupForm(NetBoxModelForm): ) slug = SlugField() + fieldsets = ( + ('Wireless LAN Group', ( + 'parent', 'name', 'slug', 'description', 'tags', + )), + ) + class Meta: model = WirelessLANGroup fields = [ @@ -31,7 +37,6 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): queryset=WirelessLANGroup.objects.all(), required=False ) - region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -76,9 +81,10 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): 'group_id': '$vlan_group', } ) + comments = CommentField() fieldsets = ( - ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'group', 'status', 'description', 'tags')), ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), @@ -87,10 +93,11 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', - 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'region', 'site_group', 'site', 'status', 'vlan_group', 'vlan', 'tenant_group', 'tenant', + 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', ] widgets = { + 'status': StaticSelect, 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, } @@ -177,6 +184,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): disabled_indicator='_occupied', label='Interface' ) + comments = CommentField() fieldsets = ( ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), @@ -190,7 +198,8 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 10b6e585b..9369df8a5 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.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 @@ -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)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=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)), ('ssid', models.CharField(max_length=32)), ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), @@ -65,7 +65,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)), ('ssid', models.CharField(blank=True, max_length=32)), ('status', models.CharField(default='connected', max_length=50)), diff --git a/netbox/wireless/migrations/0007_standardize_description_comments.py b/netbox/wireless/migrations/0007_standardize_description_comments.py new file mode 100644 index 000000000..e6e1ce8dd --- /dev/null +++ b/netbox/wireless/migrations/0007_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0006_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='wirelesslink', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/migrations/0008_wirelesslan_status.py b/netbox/wireless/migrations/0008_wirelesslan_status.py new file mode 100644 index 000000000..e7832aba2 --- /dev/null +++ b/netbox/wireless/migrations/0008_wirelesslan_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-04 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0007_standardize_description_comments'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 29fe33f4b..5858e641c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,11 +2,11 @@ from django.apps import apps from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -54,18 +54,6 @@ class WirelessLANGroup(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', 'pk') @@ -77,14 +65,11 @@ class WirelessLANGroup(NestedGroupModel): ) verbose_name = 'Wireless LAN Group' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('wireless:wirelesslangroup', args=[self.pk]) -class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -99,6 +84,11 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=WirelessLANStatusChoices, + default=WirelessLANStatusChoices.STATUS_ACTIVE + ) vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.PROTECT, @@ -113,10 +103,6 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ('ssid', 'group', 'tenant', 'description') @@ -130,6 +116,9 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): def get_absolute_url(self): return reverse('wireless:wirelesslan', args=[self.pk]) + def get_status_color(self): + return WirelessLANStatusChoices.colors.get(self.status) + def get_wireless_interface_types(): # Wrap choices in a callable to avoid generating dummy migrations @@ -137,7 +126,7 @@ def get_wireless_interface_types(): return {'type__in': WIRELESS_IFACE_TYPES} -class WirelessLink(WirelessAuthenticationBase, NetBoxModel): +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ @@ -172,10 +161,6 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their # associated Devices. diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py new file mode 100644 index 000000000..55ca2977c --- /dev/null +++ b/netbox/wireless/search.py @@ -0,0 +1,32 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class WirelessLANIndex(SearchIndex): + model = models.WirelessLAN + fields = ( + ('ssid', 100), + ('description', 500), + ('auth_psk', 2000), + ) + + +@register_search +class WirelessLANGroupIndex(SearchIndex): + model = models.WirelessLANGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + + +@register_search +class WirelessLinkIndex(SearchIndex): + model = models.WirelessLink + fields = ( + ('ssid', 100), + ('description', 500), + ('auth_psk', 2000), + ) diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index af0cdae88..5d17465f0 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -21,6 +21,7 @@ class WirelessLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Wireless LANs' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) @@ -28,7 +29,8 @@ class WirelessLANGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLANGroup fields = ( - 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated', + 'actions', ) default_columns = ('pk', 'name', 'wirelesslan_count', 'description') @@ -40,9 +42,11 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() interface_count = tables.Column( verbose_name='Interfaces' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) @@ -50,10 +54,10 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'description', 'vlan', 'interface_count', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') + default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count') class WirelessLANInterfacesTable(NetBoxTable): diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 9ef552eb7..cfc17c660 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -68,9 +68,9 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): group.save() wireless_lans = ( - WirelessLAN(ssid='WLAN1'), - WirelessLAN(ssid='WLAN2'), - WirelessLAN(ssid='WLAN3'), + WirelessLAN(ssid='WLAN1', status=WirelessLANStatusChoices.STATUS_ACTIVE), + WirelessLAN(ssid='WLAN2', status=WirelessLANStatusChoices.STATUS_ACTIVE), + WirelessLAN(ssid='WLAN3', status=WirelessLANStatusChoices.STATUS_ACTIVE), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -78,23 +78,27 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): { 'ssid': 'WLAN4', 'group': groups[0].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, }, { 'ssid': 'WLAN5', 'group': groups[1].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, }, { 'ssid': 'WLAN6', + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, }, ] cls.bulk_update_data = { + 'status': WirelessLANStatusChoices.STATUS_DEPRECATED, 'group': groups[2].pk, 'tenant': tenants[1].pk, 'description': 'New description', diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index ffe919c32..0629fea07 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -64,9 +64,18 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): groups = ( - WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), - WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), - WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + WirelessLANGroup( + name='Wireless LAN Group 1', + slug='wireless-lan-group-1' + ), + WirelessLANGroup( + name='Wireless LAN Group 2', + slug='wireless-lan-group-2' + ), + WirelessLANGroup( + name='Wireless LAN Group 3', + slug='wireless-lan-group-3' + ), ) for group in groups: group.save() @@ -86,9 +95,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) wireless_lans = ( - WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], tenant=tenants[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), - WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], tenant=tenants[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), - WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], tenant=tenants[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + WirelessLAN( + ssid='WLAN1', + group=groups[0], + status=WirelessLANStatusChoices.STATUS_ACTIVE, + vlan=vlans[0], + tenant=tenants[0], + auth_type=WirelessAuthTypeChoices.TYPE_OPEN, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, + auth_psk='PSK1' + ), + WirelessLAN( + ssid='WLAN2', + group=groups[1], + status=WirelessLANStatusChoices.STATUS_DISABLED, + vlan=vlans[1], + tenant=tenants[1], + auth_type=WirelessAuthTypeChoices.TYPE_WEP, + auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, + auth_psk='PSK2' + ), + WirelessLAN( + ssid='WLAN3', + group=groups[2], + status=WirelessLANStatusChoices.STATUS_RESERVED, + vlan=vlans[2], + tenant=tenants[2], + auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, + auth_psk='PSK3' + ), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -103,6 +139,10 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [WirelessLANStatusChoices.STATUS_ACTIVE, WirelessLANStatusChoices.STATUS_DISABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vlan(self): vlans = VLAN.objects.all()[:2] params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 7dea17d15..62c3b451f 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -32,11 +32,18 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.csv_data = ( "name,slug,description", - "Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", + "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", ) + cls.csv_update_data = ( + "id,name,description", + f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", + f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", + f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -62,29 +69,54 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - WirelessLAN.objects.bulk_create([ - WirelessLAN(group=groups[0], ssid='WLAN1', tenant=tenants[0]), - WirelessLAN(group=groups[0], ssid='WLAN2', tenant=tenants[0]), - WirelessLAN(group=groups[0], ssid='WLAN3', tenant=tenants[0]), - ]) + wireless_lans = ( + WirelessLAN( + group=groups[0], + ssid='WLAN1', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), + WirelessLAN( + group=groups[0], + ssid='WLAN2', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), + WirelessLAN( + group=groups[0], + ssid='WLAN3', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), + ) + WirelessLAN.objects.bulk_create(wireless_lans) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'ssid': 'WLAN2', 'group': groups[1].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - f"group,ssid,tenant", - f"Wireless LAN Group 2,WLAN4,{tenants[0].name}", - f"Wireless LAN Group 2,WLAN5,{tenants[1].name}", - f"Wireless LAN Group 2,WLAN6,{tenants[2].name}", + f"group,ssid,status,tenant", + f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}", + f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}", + f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}", + ) + + cls.csv_update_data = ( + f"id,ssid", + f"{wireless_lans[0].pk},WLAN7", + f"{wireless_lans[1].pk},WLAN8", + f"{wireless_lans[2].pk},WLAN9", ) cls.bulk_edit_data = { + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'description': 'New description', } @@ -115,9 +147,12 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] Interface.objects.bulk_create(interfaces) - WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]).save() - WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]).save() - WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]).save() + wirelesslink1 = WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]) + wirelesslink1.save() + wirelesslink2 = WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]) + wirelesslink2.save() + wirelesslink3 = WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]) + wirelesslink3.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -136,6 +171,13 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"{interfaces[10].pk},{interfaces[11].pk},connected,{tenants[2].name}", ) + cls.csv_update_data = ( + "id,ssid,description", + f"{wirelesslink1.pk},LINK7,New decription 7", + f"{wirelesslink2.pk},LINK8,New decription 8", + f"{wirelesslink3.pk},LINK9,New decription 9", + ) + cls.bulk_edit_data = { 'status': LinkStatusChoices.STATUS_PLANNED, } diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index cef96fd5e..cf8ea5716 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/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 = 'wireless' urlpatterns = ( @@ -13,10 +12,7 @@ urlpatterns = ( path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'), path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'), path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'), - path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), - path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), - path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), - path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + path('wireless-lan-groups//', include(get_model_urls('wireless', 'wirelesslangroup'))), # Wireless LANs path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), @@ -24,11 +20,7 @@ urlpatterns = ( path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'), path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'), path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'), - path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), - path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), - path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), - path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), - path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + path('wireless-lans//', include(get_model_urls('wireless', 'wirelesslan'))), # Wireless links path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), @@ -36,10 +28,6 @@ urlpatterns = ( path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'), path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'), path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'), - path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), - path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), - path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), - path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), - path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + path('wireless-links//', include(get_model_urls('wireless', 'wirelesslink'))), ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 988aa1b6d..e4e7617ed 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,7 @@ from dcim.models import Interface from netbox.views import generic from utilities.utils import count_related +from utilities.views import register_model_view from . import filtersets, forms, tables from .models import * @@ -22,6 +23,7 @@ class WirelessLANGroupListView(generic.ObjectListView): table = tables.WirelessLANGroupTable +@register_model_view(WirelessLANGroup) class WirelessLANGroupView(generic.ObjectView): queryset = WirelessLANGroup.objects.all() @@ -37,11 +39,13 @@ class WirelessLANGroupView(generic.ObjectView): } +@register_model_view(WirelessLANGroup, 'edit') class WirelessLANGroupEditView(generic.ObjectEditView): queryset = WirelessLANGroup.objects.all() form = forms.WirelessLANGroupForm +@register_model_view(WirelessLANGroup, 'delete') class WirelessLANGroupDeleteView(generic.ObjectDeleteView): queryset = WirelessLANGroup.objects.all() @@ -90,6 +94,7 @@ class WirelessLANListView(generic.ObjectListView): table = tables.WirelessLANTable +@register_model_view(WirelessLAN) class WirelessLANView(generic.ObjectView): queryset = WirelessLAN.objects.all() @@ -105,11 +110,13 @@ class WirelessLANView(generic.ObjectView): } +@register_model_view(WirelessLAN, 'edit') class WirelessLANEditView(generic.ObjectEditView): queryset = WirelessLAN.objects.all() form = forms.WirelessLANForm +@register_model_view(WirelessLAN, 'delete') class WirelessLANDeleteView(generic.ObjectDeleteView): queryset = WirelessLAN.objects.all() @@ -144,15 +151,18 @@ class WirelessLinkListView(generic.ObjectListView): table = tables.WirelessLinkTable +@register_model_view(WirelessLink) class WirelessLinkView(generic.ObjectView): queryset = WirelessLink.objects.all() +@register_model_view(WirelessLink, 'edit') class WirelessLinkEditView(generic.ObjectEditView): queryset = WirelessLink.objects.all() form = forms.WirelessLinkForm +@register_model_view(WirelessLink, 'delete') class WirelessLinkDeleteView(generic.ObjectDeleteView): queryset = WirelessLink.objects.all() diff --git a/requirements.txt b/requirements.txt index af91cf35b..8d748cb82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ bleach==5.0.1 -Django==4.1.1 +Django==4.1.2 django-cors-headers==3.13.0 -django-debug-toolbar==3.6.0 +django-debug-toolbar==3.7.0 django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 -django-mptt==0.13.4 +django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 @@ -13,24 +13,27 @@ django-rq==2.5.1 django-tables2==2.4.1 django-taggit==3.0.0 django-timezone-field==5.0 -djangorestframework==3.13.1 -drf-yasg[validation]==1.21.3 -graphene-django==2.15.0 +djangorestframework==3.14.0 +drf-yasg[validation]==1.21.4 +graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 -Markdown==3.4.1 -mkdocs-material==8.5.1 +Markdown==3.3.7 +mkdocs-material==8.5.7 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.2.0 -psycopg2-binary==2.9.3 +Pillow==9.3.0 +psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.9.8 +sentry-sdk==1.10.1 social-auth-app-django==5.0.0 -social-auth-core==4.3.0 +social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.2 +tzdata==2022.6 # Workaround for #7401 jsonschema==3.2.0 + +# Temporary fix for #10712 +swagger-spec-validator==2.7.6