diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 42a716ae7..744770180 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.5.7 + placeholder: v3.6.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index b04fda1b6..5cf9b72ab 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.5.7 + placeholder: v3.6.0 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b71fb515..301fac079 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,25 @@
-Some general tips for engaging here on GitHub: +## :information_source: Welcome to the Stadium! + +In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well: + +> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers. + +The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users. + +If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them. + +NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others. + +### General Tips for Working on GitHub * Register for a free [GitHub account](https://github.com/signup) if you haven't already. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. +* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them. ## :bug: Reporting Bugs diff --git a/base_requirements.txt b/base_requirements.txt index 40e0224e2..4b75b1313 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -2,13 +2,9 @@ # https://github.com/mozilla/bleach/blob/main/CHANGES bleach -# Python client for Amazon AWS API -# https://github.com/boto/boto3/blob/develop/CHANGELOG.rst -boto3 - # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<4.2 +Django<5.0 # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -74,10 +70,6 @@ drf-spectacular # https://github.com/tfranzel/drf-spectacular-sidecar drf-spectacular-sidecar -# Git client for file sync -# https://github.com/jelmer/dulwich/releases -dulwich - # RSS feed parser # https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst feedparser @@ -121,8 +113,8 @@ netaddr Pillow # PostgreSQL database adapter for Python -# https://www.psycopg.org/docs/news.html -psycopg2-binary +# https://github.com/psycopg/psycopg/blob/master/docs/news.rst +psycopg[binary,pool] # YAML rendering library # https://github.com/yaml/pyyaml/blob/master/CHANGES diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json new file mode 100644 index 000000000..9a6e2417a --- /dev/null +++ b/contrib/generated_schema.json @@ -0,0 +1,562 @@ +{ + "type": "object", + "additionalProperties": false, + "definitions": { + "airflow": { + "type": "string", + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "passive", + "mixed" + ] + }, + "weight-unit": { + "type": "string", + "enum": [ + "kg", + "g", + "lb", + "oz" + ] + }, + "subdevice-role": { + "type": "string", + "enum": [ + "parent", + "child" + ] + }, + "console-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "console-server-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "power-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c6", + "iec-60320-c8", + "iec-60320-c14", + "iec-60320-c16", + "iec-60320-c20", + "iec-60320-c22", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15p", + "nema-5-15p", + "nema-5-20p", + "nema-5-30p", + "nema-5-50p", + "nema-6-15p", + "nema-6-20p", + "nema-6-30p", + "nema-6-50p", + "nema-10-30p", + "nema-10-50p", + "nema-14-20p", + "nema-14-30p", + "nema-14-50p", + "nema-14-60p", + "nema-15-15p", + "nema-15-20p", + "nema-15-30p", + "nema-15-50p", + "nema-15-60p", + "nema-l1-15p", + "nema-l5-15p", + "nema-l5-20p", + "nema-l5-30p", + "nema-l5-50p", + "nema-l6-15p", + "nema-l6-20p", + "nema-l6-30p", + "nema-l6-50p", + "nema-l10-30p", + "nema-l14-20p", + "nema-l14-30p", + "nema-l14-50p", + "nema-l14-60p", + "nema-l15-20p", + "nema-l15-30p", + "nema-l15-50p", + "nema-l15-60p", + "nema-l21-20p", + "nema-l21-30p", + "nema-l22-30p", + "cs6361c", + "cs6365c", + "cs8165c", + "cs8265c", + "cs8365c", + "cs8465c", + "ita-c", + "ita-e", + "ita-f", + "ita-ef", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "usb-3-b", + "usb-3-micro-b", + "dc-terminal", + "saf-d-grid", + "neutrik-powercon-20", + "neutrik-powercon-32", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + } + } + }, + "power-outlet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c5", + "iec-60320-c7", + "iec-60320-c13", + "iec-60320-c15", + "iec-60320-c19", + "iec-60320-c21", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15r", + "nema-5-15r", + "nema-5-20r", + "nema-5-30r", + "nema-5-50r", + "nema-6-15r", + "nema-6-20r", + "nema-6-30r", + "nema-6-50r", + "nema-10-30r", + "nema-10-50r", + "nema-14-20r", + "nema-14-30r", + "nema-14-50r", + "nema-14-60r", + "nema-15-15r", + "nema-15-20r", + "nema-15-30r", + "nema-15-50r", + "nema-15-60r", + "nema-l1-15r", + "nema-l5-15r", + "nema-l5-20r", + "nema-l5-30r", + "nema-l5-50r", + "nema-l6-15r", + "nema-l6-20r", + "nema-l6-30r", + "nema-l6-50r", + "nema-l10-30r", + "nema-l14-20r", + "nema-l14-30r", + "nema-l14-50r", + "nema-l14-60r", + "nema-l15-20r", + "nema-l15-30r", + "nema-l15-50r", + "nema-l15-60r", + "nema-l21-20r", + "nema-l21-30r", + "nema-l22-30r", + "CS6360C", + "CS6364C", + "CS8164C", + "CS8264C", + "CS8364C", + "CS8464C", + "ita-e", + "ita-f", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "ita-multistandard", + "usb-a", + "usb-micro-b", + "usb-c", + "dc-terminal", + "hdot-cx", + "saf-d-grid", + "neutrik-powercon-20a", + "neutrik-powercon-32a", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + }, + "feed-leg": { + "type": "string", + "enum": [ + "A", + "B", + "C" + ] + } + } + }, + "interface": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "virtual", + "bridge", + "lag", + "100base-fx", + "100base-lfx", + "100base-tx", + "100base-t1", + "1000base-t", + "2.5gbase-t", + "5gbase-t", + "10gbase-t", + "10gbase-cx4", + "1000base-x-gbic", + "1000base-x-sfp", + "10gbase-x-sfpp", + "10gbase-x-xfp", + "10gbase-x-xenpak", + "10gbase-x-x2", + "25gbase-x-sfp28", + "50gbase-x-sfp56", + "40gbase-x-qsfpp", + "50gbase-x-sfp28", + "100gbase-x-cfp", + "100gbase-x-cfp2", + "200gbase-x-cfp2", + "400gbase-x-cfp2", + "100gbase-x-cfp4", + "100gbase-x-cxp", + "100gbase-x-cpak", + "100gbase-x-dsfp", + "100gbase-x-sfpdd", + "100gbase-x-qsfp28", + "100gbase-x-qsfpdd", + "200gbase-x-qsfp56", + "200gbase-x-qsfpdd", + "400gbase-x-qsfpdd", + "400gbase-x-osfp", + "400gbase-x-cdfp", + "400gbase-x-cfp8", + "800gbase-x-qsfpdd", + "800gbase-x-osfp", + "1000base-kx", + "10gbase-kr", + "10gbase-kx4", + "25gbase-kr", + "40gbase-kr4", + "50gbase-kr", + "100gbase-kp4", + "100gbase-kr2", + "100gbase-kr4", + "ieee802.11a", + "ieee802.11g", + "ieee802.11n", + "ieee802.11ac", + "ieee802.11ad", + "ieee802.11ax", + "ieee802.11ay", + "ieee802.15.1", + "other-wireless", + "gsm", + "cdma", + "lte", + "sonet-oc3", + "sonet-oc12", + "sonet-oc48", + "sonet-oc192", + "sonet-oc768", + "sonet-oc1920", + "sonet-oc3840", + "1gfc-sfp", + "2gfc-sfp", + "4gfc-sfp", + "8gfc-sfpp", + "16gfc-sfpp", + "32gfc-sfp28", + "64gfc-qsfpp", + "128gfc-qsfp28", + "infiniband-sdr", + "infiniband-ddr", + "infiniband-qdr", + "infiniband-fdr10", + "infiniband-fdr", + "infiniband-edr", + "infiniband-hdr", + "infiniband-ndr", + "infiniband-xdr", + "t1", + "e1", + "t3", + "e3", + "xdsl", + "docsis", + "gpon", + "xg-pon", + "xgs-pon", + "ng-pon2", + "epon", + "10g-epon", + "cisco-stackwise", + "cisco-stackwise-plus", + "cisco-flexstack", + "cisco-flexstack-plus", + "cisco-stackwise-80", + "cisco-stackwise-160", + "cisco-stackwise-320", + "cisco-stackwise-480", + "cisco-stackwise-1t", + "juniper-vcp", + "extreme-summitstack", + "extreme-summitstack-128", + "extreme-summitstack-256", + "extreme-summitstack-512", + "other" + ] + }, + "poe_mode": { + "type": "string", + "enum": [ + "pd", + "pse" + ] + }, + "poe_type": { + "type": "string", + "enum": [ + "type1-ieee802.3af", + "type2-ieee802.3at", + "type3-ieee802.3bt", + "type4-ieee802.3bt", + "passive-24v-2pair", + "passive-24v-4pair", + "passive-48v-2pair", + "passive-48v-4pair" + ] + } + } + }, + "front-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + }, + "rear-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + } + } +} diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 48abd5443..95f0f0c05 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. +### Default Permissions -#### Example Constraint Definitions +!!! info "This feature was introduced in NetBox v3.6." + +While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported. + +### Example Constraint Definitions | Constraints | Description | | ----------- | ----------- | diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 531f9c027..7cc4d3832 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema. By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. !!! note - These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend). + These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend). ### Archive the Media Directory diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md index aea60f389..a3e691f63 100644 --- a/docs/configuration/plugins.md +++ b/docs/configuration/plugins.md @@ -4,7 +4,7 @@ Default: Empty -A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. +A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here. !!! warning Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 1eba265bf..012d85762 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 596de1461..2ae92285f 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -4,7 +4,7 @@ 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. +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 prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. --- @@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = ( --- +## DEFAULT_PERMISSIONS + +!!! info "This parameter was introduced in NetBox v3.6." + +Default: + +```python +{ + 'users.view_token': ({'user': '$user'},), + 'users.add_token': ({'user': '$user'},), + 'users.change_token': ({'user': '$user'},), + 'users.delete_token': ({'user': '$user'},), +} +``` + +This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose. + +For example, to allow all users to create a device role beginning with the word "temp," you could configure the following: + +```python +DEFAULT_PERMISSIONS = { + 'dcim.add_devicerole': ( + {'name__startswith': 'temp'}, + ) +} +``` + +!!! warning + Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration. + +--- + ## EXEMPT_VIEW_PERMISSIONS Default: Empty list diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 612faefed..1e0d5c31e 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are ### Custom Selection Fields -Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. +Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index e20b09ae6..3811474d2 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -390,7 +390,7 @@ class NewBranchScript(Script): name=f'{site.slug}-switch{i}', site=site, status=DeviceStatusChoices.STATUS_PLANNED, - device_role=switch_role + role=switch_role ) switch.full_clean() switch.save() diff --git a/docs/customization/reports.md b/docs/customization/reports.md index b68e17bf4..7e3681304 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -111,7 +111,7 @@ The following methods are available to log results within a report: The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page. -To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`. +To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last. diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index fe2c08d56..41bf6cb31 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`. ## Stores +### `counter_fields` + +A dictionary mapping of models to foreign keys with which cached counter fields are associated. + ### `data_backends` A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md). diff --git a/docs/development/internationalization.md b/docs/development/internationalization.md new file mode 100644 index 000000000..bdc7cbdaa --- /dev/null +++ b/docs/development/internationalization.md @@ -0,0 +1,123 @@ +# Internationalization + +Beginning with NetBox v4.0, NetBox will leverage [Django's automatic translation](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) to support languages other than English. This page details the areas of the project which require special attention to ensure functioning translation support. Briefly, these include: + +* The `verbose_name` and `verbose_name_plural` Meta attributes for each model +* The `verbose_name` and (if defined) `help_text` for each model field +* The `label` for each form field +* Headers for `fieldsets` on each form class +* The `verbose_name` for each table column +* All human-readable strings within templates must be wrapped with `{% trans %}` or `{% blocktrans %}` + +The rest of this document elaborates on each of the items above. + +## General Guidance + +* Wrap human-readable strings with Django's `gettext()` or `gettext_lazy()` utility functions to enable automatic translation. Generally, `gettext_lazy()` is preferred (and sometimes required) to defer translation until the string is displayed. + +* By convention, the preferred translation function is typically imported as an underscore (`_`) to minimize boilerplate code. Thus, you will often see translation as e.g. `_("Some text")`. It is still an option to import and use alternative translation functions (e.g. `pgettext()` and `ngettext()`) normally as needed. + +* Avoid passing markup and other non-natural language where possible. Everything wrapped by a translation function gets exported to a messages file for translation by a human. + +* Where the intended meaning of the translated string may not be obvious, use `pgettext()` or `pgettext_lazy()` to include assisting context for the translator. For example: + + ```python + # Context, string + pgettext("month name", "May") + ``` + +* **Format strings do not support translation.** Avoid "f" strings for messages that must support translation. Instead, use `format()` to accomplish variable replacement: + + ```python + # Translation will not work + f"There are {count} objects" + + # Do this instead + "There are {count} objects".format(count=count) + ``` + +## Models + +1. Import `gettext_lazy` as `_`. +2. Ensure both `verbose_name` and `verbose_name_plural` are defined under the model's `Meta` class and wrapped with the `gettext_lazy()` shortcut. +3. Ensure each model field specifies a `verbose_name` wrapped with `gettext_lazy()`. +4. Ensure any `help_text` attributes on model fields are also wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class Circuit(PrimaryModel): + commit_rate = models.PositiveIntegerField( + ... + verbose_name=_('commit rate (Kbps)'), + help_text=_("Committed rate") + ) + + class Meta: + verbose_name = _('circuit') + verbose_name_plural = _('circuits') +``` + +## Forms + +1. Import `gettext_lazy` as `_`. +2. All form fields must specify a `label` wrapped with `gettext_lazy()`. +3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class CircuitBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + ... + ) + + fieldsets = ( + (_('Circuit'), ('provider', 'type', 'status', 'description')), + ) +``` + +## Tables + +1. Import `gettext_lazy` as `_`. +2. All table columns must specify a `verbose_name` wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): + provider = tables.Column( + verbose_name=_('Provider'), + ... + ) +``` + +## Templates + +1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template. +2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings. +3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. +4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps. + +``` +{% load i18n %} + +{# A short string #} +{module}
will be replaced with the position ' \
- 'of the assigned module, if any'
+ self.fields['name'].help_text += _(
+ "The string {module}
will be replaced with the position of the assigned module, if any."
+ )
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField(
+ label=_('Device'),
queryset=Device.objects.all(),
selector=True,
widget=APISelect(
@@ -329,6 +338,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(
+ label=_('Region'),
queryset=Region.objects.all(),
required=False,
initial_params={
@@ -336,6 +346,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
site_group = DynamicModelChoiceField(
+ label=_('Site group'),
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
@@ -343,6 +354,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
site = DynamicModelChoiceField(
+ label=_('Site'),
queryset=Site.objects.all(),
required=False,
query_params={
@@ -351,6 +363,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
rack = DynamicModelChoiceField(
+ label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
null_option='None',
@@ -359,6 +372,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
members = DynamicModelMultipleChoiceField(
+ label=_('Members'),
queryset=Device.objects.all(),
required=False,
query_params={
@@ -367,6 +381,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
initial_position = forms.IntegerField(
+ label=_('Initial position'),
initial=1,
required=False,
help_text=_('Position of the first member device. Increases by one for each additional member.')
@@ -383,7 +398,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({
- 'initial_position': "A position must be specified for the first VC member."
+ 'initial_position': _("A position must be specified for the first VC member.")
})
def save(self, *args, **kwargs):
diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py
index 9328a3f72..bab8876da 100644
--- a/netbox/dcim/forms/object_import.py
+++ b/netbox/dcim/forms/object_import.py
@@ -1,9 +1,10 @@
from django import forms
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
+from wireless.choices import WirelessRoleChoices
__all__ = (
'ConsolePortTemplateImportForm',
@@ -56,6 +57,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
power_port = forms.ModelChoiceField(
+ label=_('Power port'),
queryset=PowerPortTemplate.objects.all(),
to_field_name='name',
required=False
@@ -84,6 +86,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
+ label=_('Type'),
choices=InterfaceTypeChoices.CHOICES
)
poe_mode = forms.ChoiceField(
@@ -96,19 +99,27 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
required=False,
label=_('PoE type')
)
+ rf_role = forms.ChoiceField(
+ choices=WirelessRoleChoices,
+ required=False,
+ label=_('Wireless role')
+ )
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
+ 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
+ 'poe_type', 'rf_role'
]
class FrontPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
+ label=_('Type'),
choices=PortTypeChoices.CHOICES
)
rear_port = forms.ModelChoiceField(
+ label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
to_field_name='name'
)
@@ -136,6 +147,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class RearPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
+ label=_('Type'),
choices=PortTypeChoices.CHOICES
)
@@ -166,15 +178,18 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class InventoryItemTemplateImportForm(ComponentTemplateImportForm):
parent = forms.ModelChoiceField(
+ label=_('Parent'),
queryset=InventoryItemTemplate.objects.all(),
required=False
)
role = forms.ModelChoiceField(
+ label=_('Role'),
queryset=InventoryItemRole.objects.all(),
to_field_name='name',
required=False
)
manufacturer = forms.ModelChoiceField(
+ label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
required=False
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 3c6c0a885..7d7434587 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -277,6 +277,9 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
def resolve_poe_type(self, info):
return self.poe_type or None
+ def resolve_rf_role(self, info):
+ return self.rf_role or None
+
class InventoryItemType(ComponentObjectType):
component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')
diff --git a/netbox/dcim/management/commands/buildschema.py b/netbox/dcim/management/commands/buildschema.py
new file mode 100644
index 000000000..44a0e95f2
--- /dev/null
+++ b/netbox/dcim/management/commands/buildschema.py
@@ -0,0 +1,62 @@
+import json
+import os
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from jinja2 import FileSystemLoader, Environment
+
+from dcim.choices import *
+
+TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
+OUTPUT_FILENAME = 'contrib/generated_schema.json'
+
+CHOICES_MAP = {
+ 'airflow_choices': DeviceAirflowChoices,
+ 'weight_unit_choices': WeightUnitChoices,
+ 'subdevice_role_choices': SubdeviceRoleChoices,
+ 'console_port_type_choices': ConsolePortTypeChoices,
+ 'console_server_port_type_choices': ConsolePortTypeChoices,
+ 'power_port_type_choices': PowerPortTypeChoices,
+ 'power_outlet_type_choices': PowerOutletTypeChoices,
+ 'power_outlet_feedleg_choices': PowerOutletFeedLegChoices,
+ 'interface_type_choices': InterfaceTypeChoices,
+ 'interface_poe_mode_choices': InterfacePoEModeChoices,
+ 'interface_poe_type_choices': InterfacePoETypeChoices,
+ 'front_port_type_choices': PortTypeChoices,
+ 'rear_port_type_choices': PortTypeChoices,
+}
+
+
+class Command(BaseCommand):
+ help = "Generate JSON schema for validating NetBox device type definitions"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--write',
+ action='store_true',
+ help="Write the generated schema to file"
+ )
+
+ def handle(self, *args, **kwargs):
+ # Initialize template
+ template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/')
+ template_env = Environment(loader=template_loader)
+ template = template_env.get_template(TEMPLATE_FILENAME)
+
+ # Render template
+ context = {
+ key: json.dumps(choices.values())
+ for key, choices in CHOICES_MAP.items()
+ }
+ rendered = template.render(**context)
+
+ if kwargs['write']:
+ # $root/contrib/generated_schema.json
+ filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME)
+ with open(filename, mode='w', encoding='UTF-8') as f:
+ f.write(json.dumps(json.loads(rendered), indent=4))
+ f.write('\n')
+ f.close()
+ self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}."))
+ else:
+ self.stdout.write(rendered)
diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py
index b1aac0ad2..f9508424d 100644
--- a/netbox/dcim/migrations/0170_configtemplate.py
+++ b/netbox/dcim/migrations/0170_configtemplate.py
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='device',
name='config_template',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
),
migrations.AddField(
model_name='devicerole',
diff --git a/netbox/dcim/migrations/0173_remove_napalm_fields.py b/netbox/dcim/migrations/0173_remove_napalm_fields.py
new file mode 100644
index 000000000..61c7c5695
--- /dev/null
+++ b/netbox/dcim/migrations/0173_remove_napalm_fields.py
@@ -0,0 +1,19 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0172_larger_power_draw_values'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='platform',
+ name='napalm_args',
+ ),
+ migrations.RemoveField(
+ model_name='platform',
+ name='napalm_driver',
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0174_device_latitude_device_longitude.py b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py
new file mode 100644
index 000000000..f9f72f9f8
--- /dev/null
+++ b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.1.9 on 2023-05-31 22:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0173_remove_napalm_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='device',
+ name='latitude',
+ field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='longitude',
+ field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py
new file mode 100644
index 000000000..e32738660
--- /dev/null
+++ b/netbox/dcim/migrations/0174_rack_starting_unit.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.9 on 2023-05-31 15:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0174_device_latitude_device_longitude'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='rack',
+ name='starting_unit',
+ field=models.PositiveSmallIntegerField(default=1),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0175_device_oob_ip.py b/netbox/dcim/migrations/0175_device_oob_ip.py
new file mode 100644
index 000000000..bf6a88ba8
--- /dev/null
+++ b/netbox/dcim/migrations/0175_device_oob_ip.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.1.9 on 2023-07-24 20:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('ipam', '0066_iprange_mark_utilized'),
+ ('dcim', '0174_rack_starting_unit'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='device',
+ name='oob_ip',
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='ipam.ipaddress',
+ ),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py
new file mode 100644
index 000000000..a911d7fd7
--- /dev/null
+++ b/netbox/dcim/migrations/0176_device_component_counters.py
@@ -0,0 +1,108 @@
+from django.db import migrations
+from django.db.models import Count
+
+import utilities.fields
+
+
+def recalculate_device_counts(apps, schema_editor):
+ Device = apps.get_model("dcim", "Device")
+ devices = Device.objects.annotate(
+ _console_port_count=Count('consoleports', distinct=True),
+ _console_server_port_count=Count('consoleserverports', distinct=True),
+ _power_port_count=Count('powerports', distinct=True),
+ _power_outlet_count=Count('poweroutlets', distinct=True),
+ _interface_count=Count('interfaces', distinct=True),
+ _front_port_count=Count('frontports', distinct=True),
+ _rear_port_count=Count('rearports', distinct=True),
+ _device_bay_count=Count('devicebays', distinct=True),
+ _module_bay_count=Count('modulebays', distinct=True),
+ _inventory_item_count=Count('inventoryitems', distinct=True),
+ )
+
+ for device in devices:
+ device.console_port_count = device._console_port_count
+ device.console_server_port_count = device._console_server_port_count
+ device.power_port_count = device._power_port_count
+ device.power_outlet_count = device._power_outlet_count
+ device.interface_count = device._interface_count
+ device.front_port_count = device._front_port_count
+ device.rear_port_count = device._rear_port_count
+ device.device_bay_count = device._device_bay_count
+ device.module_bay_count = device._module_bay_count
+ device.inventory_item_count = device._inventory_item_count
+
+ Device.objects.bulk_update(devices, [
+ 'console_port_count',
+ 'console_server_port_count',
+ 'power_port_count',
+ 'power_outlet_count',
+ 'interface_count',
+ 'front_port_count',
+ 'rear_port_count',
+ 'device_bay_count',
+ 'module_bay_count',
+ 'inventory_item_count',
+ ], batch_size=100)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0175_device_oob_ip'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='device',
+ name='console_port_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='console_server_port_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='power_port_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='power_outlet_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='interface_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='front_port_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='rear_port_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='device_bay_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='module_bay_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='inventory_item_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'),
+ ),
+ migrations.RunPython(
+ recalculate_device_counts,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py
new file mode 100644
index 000000000..66d1460d9
--- /dev/null
+++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py
@@ -0,0 +1,108 @@
+from django.db import migrations
+from django.db.models import Count
+
+import utilities.fields
+
+
+def recalculate_devicetype_template_counts(apps, schema_editor):
+ DeviceType = apps.get_model("dcim", "DeviceType")
+ device_types = list(DeviceType.objects.all().annotate(
+ _console_port_template_count=Count('consoleporttemplates', distinct=True),
+ _console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
+ _power_port_template_count=Count('powerporttemplates', distinct=True),
+ _power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
+ _interface_template_count=Count('interfacetemplates', distinct=True),
+ _front_port_template_count=Count('frontporttemplates', distinct=True),
+ _rear_port_template_count=Count('rearporttemplates', distinct=True),
+ _device_bay_template_count=Count('devicebaytemplates', distinct=True),
+ _module_bay_template_count=Count('modulebaytemplates', distinct=True),
+ _inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
+ ))
+
+ for devicetype in device_types:
+ devicetype.console_port_template_count = devicetype._console_port_template_count
+ devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
+ devicetype.power_port_template_count = devicetype._power_port_template_count
+ devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
+ devicetype.interface_template_count = devicetype._interface_template_count
+ devicetype.front_port_template_count = devicetype._front_port_template_count
+ devicetype.rear_port_template_count = devicetype._rear_port_template_count
+ devicetype.device_bay_template_count = devicetype._device_bay_template_count
+ devicetype.module_bay_template_count = devicetype._module_bay_template_count
+ devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
+
+ DeviceType.objects.bulk_update(device_types, [
+ 'console_port_template_count',
+ 'console_server_port_template_count',
+ 'power_port_template_count',
+ 'power_outlet_template_count',
+ 'interface_template_count',
+ 'front_port_template_count',
+ 'rear_port_template_count',
+ 'device_bay_template_count',
+ 'module_bay_template_count',
+ 'inventory_item_template_count',
+ ])
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0176_device_component_counters'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicetype',
+ name='console_port_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='console_server_port_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='power_port_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='power_outlet_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='interface_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='front_port_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='rear_port_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='device_bay_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='module_bay_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='inventory_item_template_count',
+ field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'),
+ ),
+ migrations.RunPython(
+ recalculate_devicetype_template_counts,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
new file mode 100644
index 000000000..7d07a4d9d
--- /dev/null
+++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
@@ -0,0 +1,35 @@
+from django.db import migrations
+from django.db.models import Count
+
+import utilities.fields
+
+
+def populate_virtualchassis_members(apps, schema_editor):
+ VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
+
+ vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
+
+ for vc in vcs:
+ vc.member_count = vc._member_count
+
+ VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0177_devicetype_component_counters'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualchassis',
+ name='member_count',
+ field=utilities.fields.CounterCacheField(
+ default=0, to_field='virtual_chassis', to_model='dcim.Device'
+ ),
+ ),
+ migrations.RunPython(
+ code=populate_virtualchassis_members,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py b/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py
new file mode 100644
index 000000000..44eb08853
--- /dev/null
+++ b/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.2 on 2023-07-18 07:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0178_virtual_chassis_member_counter'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interfacetemplate',
+ name='rf_role',
+ field=models.CharField(blank=True, max_length=30),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0180_powerfeed_tenant.py b/netbox/dcim/migrations/0180_powerfeed_tenant.py
new file mode 100644
index 000000000..af550b21d
--- /dev/null
+++ b/netbox/dcim/migrations/0180_powerfeed_tenant.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.1.8 on 2023-07-29 11:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0010_tenant_relax_uniqueness'),
+ ('dcim', '0179_interfacetemplate_rf_role'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='powerfeed',
+ name='tenant',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0181_rename_device_role_device_role.py b/netbox/dcim/migrations/0181_rename_device_role_device_role.py
new file mode 100644
index 000000000..e32e00221
--- /dev/null
+++ b/netbox/dcim/migrations/0181_rename_device_role_device_role.py
@@ -0,0 +1,35 @@
+from django.db import migrations
+
+
+def update_table_configs(apps, schema_editor):
+ """
+ Replace the `device_role` column in DeviceTable configs with `role`.
+ """
+ UserConfig = apps.get_model('users', 'UserConfig')
+
+ for table in ('DeviceTable', 'DeviceBayTable'):
+ for config in UserConfig.objects.filter(**{f'data__tables__{table}__columns__contains': 'device_role'}):
+ config.data['tables'][table]['columns'] = [
+ 'role' if x == 'device_role' else x
+ for x in config.data['tables'][table]['columns']
+ ]
+ config.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0180_powerfeed_tenant'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='device',
+ old_name='device_role',
+ new_name='role',
+ ),
+ migrations.RunPython(
+ code=update_table_configs,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index c01a26dcc..d45361fda 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -8,6 +8,7 @@ from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -40,11 +41,13 @@ class Cable(PrimaryModel):
A physical connection between two endpoints.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=CableTypeChoices,
blank=True
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
@@ -57,19 +60,23 @@ class Cable(PrimaryModel):
null=True
)
label = models.CharField(
+ verbose_name=_('label'),
max_length=100,
blank=True
)
color = ColorField(
+ verbose_name=_('color'),
blank=True
)
length = models.DecimalField(
+ verbose_name=_('length'),
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
length_unit = models.CharField(
+ verbose_name=_('length unit'),
max_length=50,
choices=CableLengthUnitChoices,
blank=True,
@@ -84,6 +91,8 @@ class Cable(PrimaryModel):
class Meta:
ordering = ('pk',)
+ verbose_name = _('cable')
+ verbose_name_plural = _('cables')
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
super().__init__(*args, **kwargs)
@@ -235,7 +244,7 @@ class CableTermination(ChangeLoggedModel):
cable_end = models.CharField(
max_length=1,
choices=CableEndChoices,
- verbose_name='End'
+ verbose_name=_('end')
)
termination_type = models.ForeignKey(
to=ContentType,
@@ -285,6 +294,8 @@ class CableTermination(ChangeLoggedModel):
name='%(app_label)s_%(class)s_unique_termination'
),
)
+ verbose_name = _('cable termination')
+ verbose_name_plural = _('cable terminations')
def __str__(self):
return f'Cable {self.cable} to {self.termination}'
@@ -359,6 +370,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
+ cache_related_objects.alters_data = True
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@@ -402,19 +414,27 @@ class CablePath(models.Model):
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
"""
path = models.JSONField(
+ verbose_name=_('path'),
default=list
)
is_active = models.BooleanField(
+ verbose_name=_('is active'),
default=False
)
is_complete = models.BooleanField(
+ verbose_name=_('is complete'),
default=False
)
is_split = models.BooleanField(
+ verbose_name=_('is split'),
default=False
)
_nodes = PathField()
+ class Meta:
+ verbose_name = _('cable path')
+ verbose_name_plural = _('cable paths')
+
def __str__(self):
return f"Path #{self.pk}: {len(self.path)} hops"
@@ -676,6 +696,7 @@ class CablePath(models.Model):
self.save()
else:
self.delete()
+ retrace.alters_data = True
def _get_path(self):
"""
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 6a89655b2..f58d2bbca 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -12,6 +12,8 @@ from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
+from utilities.tracking import TrackingModelMixin
+from wireless.choices import WirelessRoleChoices
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort,
@@ -32,17 +34,18 @@ __all__ = (
)
-class ComponentTemplateModel(ChangeLoggedModel):
+class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='%(class)ss'
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=64,
- help_text="""
- {module} is accepted as a substitution for the module bay position when attached to a module type.
- """
+ help_text=_(
+ "{module} is accepted as a substitution for the module bay position when attached to a module type."
+ )
)
_name = NaturalOrderingField(
target_field='name',
@@ -50,11 +53,13 @@ class ComponentTemplateModel(ChangeLoggedModel):
blank=True
)
label = models.CharField(
+ verbose_name=_('label'),
max_length=64,
blank=True,
- help_text=_("Physical label")
+ help_text=_('Physical label')
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True
)
@@ -96,7 +101,7 @@ class ComponentTemplateModel(ChangeLoggedModel):
if self.pk is not None and self._original_device_type != self.device_type_id:
raise ValidationError({
- "device_type": "Component templates cannot be moved to a different device type."
+ "device_type": _("Component templates cannot be moved to a different device type.")
})
@@ -147,11 +152,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
# A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type:
raise ValidationError(
- "A component template cannot be associated with both a device type and a module type."
+ _("A component template cannot be associated with both a device type and a module type.")
)
if not self.device_type and not self.module_type:
raise ValidationError(
- "A component template must be associated with either a device type or a module type."
+ _("A component template must be associated with either a device type or a module type.")
)
def resolve_name(self, module):
@@ -170,6 +175,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
A template for a ConsolePort to be created for a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
@@ -177,6 +183,10 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
component_model = ConsolePort
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('console port template')
+ verbose_name_plural = _('console port templates')
+
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -199,6 +209,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
A template for a ConsoleServerPort to be created for a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
@@ -206,6 +217,10 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
component_model = ConsoleServerPort
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('console server port template')
+ verbose_name_plural = _('console server port templates')
+
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -213,6 +228,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
type=self.type,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -228,25 +244,32 @@ class PowerPortTemplate(ModularComponentTemplateModel):
A template for a PowerPort to be created for a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PowerPortTypeChoices,
blank=True
)
maximum_draw = models.PositiveIntegerField(
+ verbose_name=_('maximum draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text=_("Maximum power draw (watts)")
+ help_text=_('Maximum power draw (watts)')
)
allocated_draw = models.PositiveIntegerField(
+ verbose_name=_('allocated draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text=_("Allocated power draw (watts)")
+ help_text=_('Allocated power draw (watts)')
)
component_model = PowerPort
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('power port template')
+ verbose_name_plural = _('power port templates')
+
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -256,6 +279,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
allocated_draw=self.allocated_draw,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def clean(self):
super().clean()
@@ -263,7 +287,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
- 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+ 'allocated_draw': _("Allocated draw cannot exceed the maximum draw ({maximum_draw}W).").format(maximum_draw=self.maximum_draw)
})
def to_yaml(self):
@@ -282,6 +306,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
A template for a PowerOutlet to be created for a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PowerOutletTypeChoices,
blank=True
@@ -294,14 +319,19 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
related_name='poweroutlet_templates'
)
feed_leg = models.CharField(
+ verbose_name=_('feed leg'),
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
- help_text=_("Phase (for three-phase feeds)")
+ help_text=_('Phase (for three-phase feeds)')
)
component_model = PowerOutlet
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('power outlet template')
+ verbose_name_plural = _('power outlet templates')
+
def clean(self):
super().clean()
@@ -309,11 +339,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
if self.power_port:
if self.device_type and self.power_port.device_type != self.device_type:
raise ValidationError(
- f"Parent power port ({self.power_port}) must belong to the same device type"
+ _("Parent power port ({power_port}) must belong to the same device type").format(power_port=self.power_port)
)
if self.module_type and self.power_port.module_type != self.module_type:
raise ValidationError(
- f"Parent power port ({self.power_port}) must belong to the same module type"
+ _("Parent power port ({power_port}) must belong to the same module type").format(power_port=self.power_port)
)
def instantiate(self, **kwargs):
@@ -330,6 +360,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
feed_leg=self.feed_leg,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -354,15 +385,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
blank=True
)
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=InterfaceTypeChoices
)
enabled = models.BooleanField(
+ verbose_name=_('enabled'),
default=True
)
mgmt_only = models.BooleanField(
default=False,
- verbose_name='Management only'
+ verbose_name=_('management only')
)
bridge = models.ForeignKey(
to='self',
@@ -370,38 +403,53 @@ class InterfaceTemplate(ModularComponentTemplateModel):
related_name='bridge_interfaces',
null=True,
blank=True,
- verbose_name='Bridge interface'
+ verbose_name=_('bridge interface')
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
- verbose_name='PoE mode'
+ verbose_name=_('PoE mode')
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
- verbose_name='PoE type'
+ verbose_name=_('PoE type')
+ )
+ rf_role = models.CharField(
+ max_length=30,
+ choices=WirelessRoleChoices,
+ blank=True,
+ verbose_name=_('wireless role')
)
component_model = Interface
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('interface template')
+ verbose_name_plural = _('interface templates')
+
def clean(self):
super().clean()
if self.bridge:
if self.pk and self.bridge_id == self.pk:
- raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+ raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
- 'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
+ 'bridge': _("Bridge interface ({bridge}) must belong to the same device type").format(bridge=self.bridge)
})
if self.module_type and self.module_type != self.bridge.module_type:
raise ValidationError({
- 'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
+ 'bridge': _("Bridge interface ({bridge}) must belong to the same module type").format(bridge=self.bridge)
})
+ if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
+ raise ValidationError({
+ 'rf_role': "Wireless role may be set only on wireless interfaces."
+ })
+
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -411,8 +459,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
poe_type=self.poe_type,
+ rf_role=self.rf_role,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -425,6 +475,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
+ 'rf_role': self.rf_role,
}
@@ -433,10 +484,12 @@ class FrontPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the front of a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
+ verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
@@ -445,6 +498,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
related_name='frontport_templates'
)
rear_port_position = models.PositiveSmallIntegerField(
+ verbose_name=_('rear port position'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -469,6 +523,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
+ verbose_name = _('front port template')
+ verbose_name_plural = _('front port templates')
def clean(self):
super().clean()
@@ -478,13 +534,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
- "Rear port ({}) must belong to the same device type".format(self.rear_port)
+ _("Rear port ({}) must belong to the same device type").format(self.rear_port)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
- "Invalid rear port position ({}); rear port {} has only {} positions".format(
+ _("Invalid rear port position ({}); rear port {} has only {} positions").format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
@@ -507,6 +563,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
rear_port_position=self.rear_port_position,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -525,13 +582,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the rear of a new Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
+ verbose_name=_('color'),
blank=True
)
positions = models.PositiveSmallIntegerField(
+ verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -541,6 +601,10 @@ class RearPortTemplate(ModularComponentTemplateModel):
component_model = RearPort
+ class Meta(ModularComponentTemplateModel.Meta):
+ verbose_name = _('rear port template')
+ verbose_name_plural = _('rear port templates')
+
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -550,6 +614,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
positions=self.positions,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -567,6 +632,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
A template for a ModuleBay to be created for a new parent Device.
"""
position = models.CharField(
+ verbose_name=_('position'),
max_length=30,
blank=True,
help_text=_('Identifier to reference when renaming installed components')
@@ -574,6 +640,10 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay
+ class Meta(ComponentTemplateModel.Meta):
+ verbose_name = _('module bay template')
+ verbose_name_plural = _('module bay templates')
+
def instantiate(self, device):
return self.component_model(
device=device,
@@ -581,6 +651,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
label=self.label,
position=self.position
)
+ instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -597,17 +668,22 @@ class DeviceBayTemplate(ComponentTemplateModel):
"""
component_model = DeviceBay
+ class Meta(ComponentTemplateModel.Meta):
+ verbose_name = _('device bay template')
+ verbose_name_plural = _('device bay templates')
+
def instantiate(self, device):
return self.component_model(
device=device,
name=self.name,
label=self.label
)
+ instantiate.do_not_call_in_templates = True
def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
raise ValidationError(
- f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
+ _("Subdevice role of device type ({device_type}) must be set to \"parent\" to allow device bays.").format(device_type=self.device_type)
)
def to_yaml(self):
@@ -662,7 +738,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
)
part_id = models.CharField(
max_length=50,
- verbose_name='Part ID',
+ verbose_name=_('part ID'),
blank=True,
help_text=_('Manufacturer-assigned part identifier')
)
@@ -678,6 +754,8 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_device_type_parent_name'
),
)
+ verbose_name = _('inventory item template')
+ verbose_name_plural = _('inventory item templates')
def instantiate(self, **kwargs):
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
@@ -696,3 +774,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
part_id=self.part_id,
**kwargs
)
+ instantiate.do_not_call_in_templates = True
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 9f6837b92..e18f25e4f 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -7,7 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar
+from utilities.tracking import TrackingModelMixin
from wireless.choices import *
from wireless.utils import get_channel_attr
@@ -51,6 +52,7 @@ class ComponentModel(NetBoxModel):
related_name='%(class)ss'
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=64
)
_name = NaturalOrderingField(
@@ -59,11 +61,13 @@ class ComponentModel(NetBoxModel):
blank=True
)
label = models.CharField(
+ verbose_name=_('label'),
max_length=64,
blank=True,
- help_text=_("Physical label")
+ help_text=_('Physical label')
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True
)
@@ -100,7 +104,7 @@ class ComponentModel(NetBoxModel):
# Check list of Modules that allow device field to be changed
if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
raise ValidationError({
- "device": "Components cannot be moved to a different device."
+ "device": _("Components cannot be moved to a different device.")
})
@property
@@ -139,13 +143,15 @@ class CabledObjectModel(models.Model):
null=True
)
cable_end = models.CharField(
+ verbose_name=_('cable end'),
max_length=1,
blank=True,
choices=CableEndChoices
)
mark_connected = models.BooleanField(
+ verbose_name=_('mark connected'),
default=False,
- help_text=_("Treat as if a cable is connected")
+ help_text=_('Treat as if a cable is connected')
)
cable_terminations = GenericRelation(
@@ -163,15 +169,15 @@ class CabledObjectModel(models.Model):
if self.cable and not self.cable_end:
raise ValidationError({
- "cable_end": "Must specify cable end (A or B) when attaching a cable."
+ "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
})
if self.cable_end and not self.cable:
raise ValidationError({
- "cable_end": "Cable end must not be set without a cable."
+ "cable_end": _("Cable end must not be set without a cable.")
})
if self.mark_connected and self.cable:
raise ValidationError({
- "mark_connected": "Cannot mark as connected with a cable attached."
+ "mark_connected": _("Cannot mark as connected with a cable attached.")
})
@property
@@ -194,7 +200,9 @@ class CabledObjectModel(models.Model):
@property
def parent_object(self):
- raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
+ raise NotImplementedError(
+ _("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
+ )
@property
def opposite_cable_end(self):
@@ -269,17 +277,19 @@ class PathEndpoint(models.Model):
# Console components
#
-class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
+class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
+ verbose_name=_('speed'),
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
@@ -288,21 +298,27 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed')
+ class Meta(ModularComponentModel.Meta):
+ verbose_name = _('console port')
+ verbose_name_plural = _('console ports')
+
def get_absolute_url(self):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
-class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
+class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
+ verbose_name=_('speed'),
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
@@ -311,6 +327,10 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed')
+ class Meta(ModularComponentModel.Meta):
+ verbose_name = _('console server port')
+ verbose_name_plural = _('console server ports')
+
def get_absolute_url(self):
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
@@ -319,31 +339,38 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components
#
-class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
+class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PowerPortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
maximum_draw = models.PositiveIntegerField(
+ verbose_name=_('maximum draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveIntegerField(
+ verbose_name=_('allocated draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text=_("Allocated power draw (watts)")
+ help_text=_('Allocated power draw (watts)')
)
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
+ class Meta(ModularComponentModel.Meta):
+ verbose_name = _('power port')
+ verbose_name_plural = _('power ports')
+
def get_absolute_url(self):
return reverse('dcim:powerport', kwargs={'pk': self.pk})
@@ -353,7 +380,9 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
- 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+ 'allocated_draw': _(
+ "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
+ ).format(maximum_draw=self.maximum_draw)
})
def get_downstream_powerports(self, leg=None):
@@ -428,11 +457,12 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
}
-class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
+class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PowerOutletTypeChoices,
blank=True,
@@ -446,14 +476,19 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
related_name='poweroutlets'
)
feed_leg = models.CharField(
+ verbose_name=_('feed leg'),
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
- help_text=_("Phase (for three-phase feeds)")
+ help_text=_('Phase (for three-phase feeds)')
)
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
+ class Meta(ModularComponentModel.Meta):
+ verbose_name = _('power outlet')
+ verbose_name_plural = _('power outlets')
+
def get_absolute_url(self):
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
@@ -462,7 +497,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
- raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
+ raise ValidationError(
+ _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
+ )
#
@@ -474,12 +511,13 @@ class BaseInterface(models.Model):
Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
"""
enabled = models.BooleanField(
+ verbose_name=_('enabled'),
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
- verbose_name='MAC Address'
+ verbose_name=_('MAC address')
)
mtu = models.PositiveIntegerField(
blank=True,
@@ -488,13 +526,14 @@ class BaseInterface(models.Model):
MinValueValidator(INTERFACE_MTU_MIN),
MaxValueValidator(INTERFACE_MTU_MAX)
],
- verbose_name='MTU'
+ verbose_name=_('MTU')
)
mode = models.CharField(
+ verbose_name=_('mode'),
max_length=50,
choices=InterfaceModeChoices,
blank=True,
- help_text=_("IEEE 802.1Q tagging strategy")
+ help_text=_('IEEE 802.1Q tagging strategy')
)
parent = models.ForeignKey(
to='self',
@@ -502,7 +541,7 @@ class BaseInterface(models.Model):
related_name='child_interfaces',
null=True,
blank=True,
- verbose_name='Parent interface'
+ verbose_name=_('parent interface')
)
bridge = models.ForeignKey(
to='self',
@@ -510,7 +549,7 @@ class BaseInterface(models.Model):
related_name='bridge_interfaces',
null=True,
blank=True,
- verbose_name='Bridge interface'
+ verbose_name=_('bridge interface')
)
class Meta:
@@ -537,7 +576,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
-class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
+class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -558,23 +597,25 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='member_interfaces',
null=True,
blank=True,
- verbose_name='Parent LAG'
+ verbose_name=_('parent LAG')
)
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=InterfaceTypeChoices
)
mgmt_only = models.BooleanField(
default=False,
- verbose_name='Management only',
+ verbose_name=_('management only'),
help_text=_('This interface is used only for out-of-band management')
)
speed = models.PositiveIntegerField(
blank=True,
null=True,
- verbose_name='Speed (Kbps)'
+ verbose_name=_('speed (Kbps)')
)
duplex = models.CharField(
+ verbose_name=_('duplex'),
max_length=50,
blank=True,
null=True,
@@ -583,27 +624,27 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
wwn = WWNField(
null=True,
blank=True,
- verbose_name='WWN',
+ verbose_name=_('WWN'),
help_text=_('64-bit World Wide Name')
)
rf_role = models.CharField(
max_length=30,
choices=WirelessRoleChoices,
blank=True,
- verbose_name='Wireless role'
+ verbose_name=_('wireless role')
)
rf_channel = models.CharField(
max_length=50,
choices=WirelessChannelChoices,
blank=True,
- verbose_name='Wireless channel'
+ verbose_name=_('wireless channel')
)
rf_channel_frequency = models.DecimalField(
max_digits=7,
decimal_places=2,
blank=True,
null=True,
- verbose_name='Channel frequency (MHz)',
+ verbose_name=_('channel frequency (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
rf_channel_width = models.DecimalField(
@@ -611,26 +652,26 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
decimal_places=3,
blank=True,
null=True,
- verbose_name='Channel width (MHz)',
+ verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
tx_power = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=(MaxValueValidator(127),),
- verbose_name='Transmit power (dBm)'
+ verbose_name=_('transmit power (dBm)')
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
- verbose_name='PoE mode'
+ verbose_name=_('PoE mode')
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
- verbose_name='PoE type'
+ verbose_name=_('PoE type')
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
@@ -643,7 +684,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
to='wireless.WirelessLAN',
related_name='interfaces',
blank=True,
- verbose_name='Wireless LANs'
+ verbose_name=_('wireless LANs')
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
@@ -651,13 +692,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces_as_untagged',
null=True,
blank=True,
- verbose_name='Untagged VLAN'
+ verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True,
- verbose_name='Tagged VLANs'
+ verbose_name=_('tagged VLANs')
)
vrf = models.ForeignKey(
to='ipam.VRF',
@@ -665,7 +706,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces',
null=True,
blank=True,
- verbose_name='VRF'
+ verbose_name=_('VRF')
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
@@ -693,6 +734,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
class Meta(ModularComponentModel.Meta):
ordering = ('device', CollateAsChar('_name'))
+ verbose_name = _('interface')
+ verbose_name_plural = _('interfaces')
def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk})
@@ -703,77 +746,98 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Virtual Interfaces cannot have a Cable attached
if self.is_virtual and self.cable:
raise ValidationError({
- 'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
+ 'type': _("{display_type} interfaces cannot have a cable attached.").format(
+ display_type=self.get_type_display()
+ )
})
# Virtual Interfaces cannot be marked as connected
if self.is_virtual and self.mark_connected:
raise ValidationError({
- 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
+ 'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
+ display_type=self.get_type_display())
+ )
})
# Parent validation
# An interface cannot be its own parent
if self.pk and self.parent_id == self.pk:
- raise ValidationError({'parent': "An interface cannot be its own parent."})
+ raise ValidationError({'parent': _("An interface cannot be its own parent.")})
# A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
- raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+ raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
# An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
- 'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
- f"({self.parent.device})."
+ 'parent': _(
+ "The selected parent interface ({interface}) belongs to a different device ({device})"
+ ).format(interface=self.parent, device=self.parent.device)
})
elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
raise ValidationError({
- 'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
- f"is not part of virtual chassis {self.device.virtual_chassis}."
+ 'parent': _(
+ "The selected parent interface ({interface}) belongs to {device}, which is not part of "
+ "virtual chassis {virtual_chassis}."
+ ).format(
+ interface=self.parent,
+ device=self.parent_device,
+ virtual_chassis=self.device.virtual_chassis
+ )
})
# Bridge validation
# An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk:
- raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+ raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
# A bridged interface belong to the same device or virtual chassis
if self.bridge and self.bridge.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
- 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device "
- f"({self.bridge.device})."
+ 'bridge': _("""
+ The selected bridge interface ({bridge}) belongs to a different device
+ ({device}).""").format(bridge=self.bridge, device=self.bridge.device)
})
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
- 'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which "
- f"is not part of virtual chassis {self.device.virtual_chassis}."
+ 'bridge': _(
+ "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
+ "chassis {virtual_chassis}."
+ ).format(
+ interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
+ )
})
# LAG validation
# A virtual interface cannot have a parent LAG
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
- raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
+ raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
# A LAG interface cannot be its own parent
if self.pk and self.lag_id == self.pk:
- raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
+ raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
- 'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
+ 'lag': _(
+ "The selected LAG interface ({lag}) belongs to a different device ({device})."
+ ).format(lag=self.lag, device=self.lag.device)
})
elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
- 'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
- f"of virtual chassis {self.device.virtual_chassis}."
+ 'lag': _(
+ "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
+ "{virtual_chassis}.".format(
+ lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
+ )
})
# PoE validation
@@ -781,52 +845,54 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
- 'poe_mode': "Virtual interfaces cannot have a PoE mode."
+ 'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
})
if self.poe_type and self.is_virtual:
raise ValidationError({
- 'poe_type': "Virtual interfaces cannot have a PoE type."
+ 'poe_type': _("Virtual interfaces cannot have a PoE type.")
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
- 'poe_type': "Must specify PoE mode when designating a PoE type."
+ 'poe_type': _("Must specify PoE mode when designating a PoE type.")
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces
if self.rf_role and not self.is_wireless:
- raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
+ raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
if self.rf_channel and not self.is_wireless:
- raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
+ raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
# Validate channel frequency against interface type and selected channel (if any)
if self.rf_channel_frequency:
if not self.is_wireless:
raise ValidationError({
- 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
+ 'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
})
if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
raise ValidationError({
- 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
+ 'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
})
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
if not self.is_wireless:
- raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
+ raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
- raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
+ raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
# VLAN validation
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
- 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
- f"interface's parent device, or it must be global."
+ 'untagged_vlan': _("""
+ The untagged VLAN ({untagged_vlan}) must belong to the same site as the
+ interface's parent device, or it must be global.
+ """).format(untagged_vlan=self.untagged_vlan)
})
def save(self, *args, **kwargs):
@@ -888,15 +954,17 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Pass-through ports
#
-class FrontPort(ModularComponentModel, CabledObjectModel):
+class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
A pass-through port on the front of a Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
+ verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
@@ -905,6 +973,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
related_name='frontports'
)
rear_port_position = models.PositiveSmallIntegerField(
+ verbose_name=_('rear port position'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -926,6 +995,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
+ verbose_name = _('front port')
+ verbose_name_plural = _('front ports')
def get_absolute_url(self):
return reverse('dcim:frontport', kwargs={'pk': self.pk})
@@ -938,29 +1009,40 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError({
- "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
+ "rear_port": _(
+ "Rear port ({rear_port}) must belong to the same device"
+ ).format(rear_port=self.rear_port)
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
- "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
- f"{self.rear_port.name} has only {self.rear_port.positions} positions"
+ "rear_port_position": _(
+ "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
+ "positions."
+ ).format(
+ rear_port_position=self.rear_port_position,
+ name=self.rear_port.name,
+ positions=self.rear_port.positions
+ )
})
-class RearPort(ModularComponentModel, CabledObjectModel):
+class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
A pass-through port on the rear of a Device.
"""
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
+ verbose_name=_('color'),
blank=True
)
positions = models.PositiveSmallIntegerField(
+ verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -970,6 +1052,10 @@ class RearPort(ModularComponentModel, CabledObjectModel):
)
clone_fields = ('device', 'type', 'color', 'positions')
+ class Meta(ModularComponentModel.Meta):
+ verbose_name = _('rear port')
+ verbose_name_plural = _('rear ports')
+
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
@@ -981,8 +1067,9 @@ class RearPort(ModularComponentModel, CabledObjectModel):
frontport_count = self.frontports.count()
if self.positions < frontport_count:
raise ValidationError({
- "positions": f"The number of positions cannot be less than the number of mapped front ports "
- f"({frontport_count})"
+ "positions": _("""
+ The number of positions cannot be less than the number of mapped front ports
+ ({frontport_count})""").format(frontport_count=frontport_count)
})
@@ -990,11 +1077,12 @@ class RearPort(ModularComponentModel, CabledObjectModel):
# Bays
#
-class ModuleBay(ComponentModel):
+class ModuleBay(ComponentModel, TrackingModelMixin):
"""
An empty space within a Device which can house a child device
"""
position = models.CharField(
+ verbose_name=_('position'),
max_length=30,
blank=True,
help_text=_('Identifier to reference when renaming installed components')
@@ -1002,24 +1090,32 @@ class ModuleBay(ComponentModel):
clone_fields = ('device',)
+ class Meta(ComponentModel.Meta):
+ verbose_name = _('module bay')
+ verbose_name_plural = _('module bays')
+
def get_absolute_url(self):
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
-class DeviceBay(ComponentModel):
+class DeviceBay(ComponentModel, TrackingModelMixin):
"""
An empty space within a Device which can house a child device
"""
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
- related_name='parent_bay',
+ related_name=_('parent_bay'),
blank=True,
null=True
)
clone_fields = ('device',)
+ class Meta(ComponentModel.Meta):
+ verbose_name = _('device bay')
+ verbose_name_plural = _('device bays')
+
def get_absolute_url(self):
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
@@ -1028,22 +1124,22 @@ class DeviceBay(ComponentModel):
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:
- raise ValidationError("This type of device ({}) does not support device bays.".format(
- self.device.device_type
+ raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
+ device_type=self.device.device_type
))
# Cannot install a device into itself, obviously
if self.device == self.installed_device:
- raise ValidationError("Cannot install a device into itself.")
+ raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere
if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
if current_bay and current_bay != self:
raise ValidationError({
- 'installed_device': "Cannot install the specified device; device is already installed in {}".format(
- current_bay
- )
+ 'installed_device': _(
+ "Cannot install the specified device; device is already installed in {bay}."
+ ).format(bay=current_bay)
})
@@ -1057,14 +1153,20 @@ class InventoryItemRole(OrganizationalModel):
Inventory items may optionally be assigned a functional role.
"""
color = ColorField(
+ verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('inventory item role')
+ verbose_name_plural = _('inventory item roles')
+
def get_absolute_url(self):
return reverse('dcim:inventoryitemrole', args=[self.pk])
-class InventoryItem(MPTTModel, ComponentModel):
+class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
@@ -1109,13 +1211,13 @@ class InventoryItem(MPTTModel, ComponentModel):
)
part_id = models.CharField(
max_length=50,
- verbose_name='Part ID',
+ verbose_name=_('part ID'),
blank=True,
help_text=_('Manufacturer-assigned part identifier')
)
serial = models.CharField(
max_length=50,
- verbose_name='Serial number',
+ verbose_name=_('serial number'),
blank=True
)
asset_tag = models.CharField(
@@ -1123,10 +1225,11 @@ class InventoryItem(MPTTModel, ComponentModel):
unique=True,
blank=True,
null=True,
- verbose_name='Asset tag',
+ verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this item')
)
discovered = models.BooleanField(
+ verbose_name=_('discovered'),
default=False,
help_text=_('This item was automatically discovered')
)
@@ -1143,6 +1246,8 @@ class InventoryItem(MPTTModel, ComponentModel):
name='%(app_label)s_%(class)s_unique_device_parent_name'
),
)
+ verbose_name = _('inventory item')
+ verbose_name_plural = _('inventory items')
def get_absolute_url(self):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
@@ -1153,7 +1258,7 @@ class InventoryItem(MPTTModel, ComponentModel):
# An InventoryItem cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
- "parent": "Cannot assign self as parent."
+ "parent": _("Cannot assign self as parent.")
})
# Validation for moving InventoryItems
@@ -1161,13 +1266,13 @@ class InventoryItem(MPTTModel, ComponentModel):
# Cannot move an InventoryItem to another device if it has a parent
if self.parent and self.parent.device != self.device:
raise ValidationError({
- "parent": "Parent inventory item does not belong to the same device."
+ "parent": _("Parent inventory item does not belong to the same device.")
})
# Prevent moving InventoryItems with children
first_child = self.get_children().first()
if first_child and first_child.device != self.device:
- raise ValidationError("Cannot move an inventory item with dependent children")
+ raise ValidationError(_("Cannot move an inventory item with dependent children"))
# When moving an InventoryItem to another device, remove any associated component
if self.component and self.component.device != self.device:
@@ -1175,5 +1280,5 @@ class InventoryItem(MPTTModel, ComponentModel):
else:
if self.component and self.component.device != self.device:
raise ValidationError({
- "device": "Cannot assign inventory item to component on another device"
+ "device": _("Cannot assign inventory item to component on another device")
})
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index fbc92e1fe..857251caf 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -3,7 +3,6 @@ import yaml
from functools import cached_property
-from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -12,7 +11,7 @@ from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
from django.utils.safestring import mark_safe
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -20,10 +19,12 @@ from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.choices import ColorChoices
-from utilities.fields import ColorField, NaturalOrderingField
+from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
+from utilities.tracking import TrackingModelMixin
from .device_components import *
-from .mixins import WeightMixin
+from .mixins import RenderConfigMixin, WeightMixin
__all__ = (
@@ -43,20 +44,20 @@ __all__ = (
# Device Types
#
-class Manufacturer(OrganizationalModel):
+class Manufacturer(ContactsMixin, OrganizationalModel):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
"""
- # Generic relations
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('manufacturer')
+ verbose_name_plural = _('manufacturers')
def get_absolute_url(self):
return reverse('dcim:manufacturer', args=[self.pk])
-class DeviceType(PrimaryModel, WeightMixin):
+class DeviceType(ImageAttachmentsMixin, 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).
@@ -77,9 +78,11 @@ class DeviceType(PrimaryModel, WeightMixin):
related_name='device_types'
)
model = models.CharField(
+ verbose_name=_('model'),
max_length=100
)
slug = models.SlugField(
+ verbose_name=_('slug'),
max_length=100
)
default_platform = models.ForeignKey(
@@ -88,9 +91,10 @@ class DeviceType(PrimaryModel, WeightMixin):
related_name='+',
blank=True,
null=True,
- verbose_name='Default platform'
+ verbose_name=_('default platform')
)
part_number = models.CharField(
+ verbose_name=_('part number'),
max_length=50,
blank=True,
help_text=_('Discrete part number (optional)')
@@ -99,22 +103,23 @@ class DeviceType(PrimaryModel, WeightMixin):
max_digits=4,
decimal_places=1,
default=1.0,
- verbose_name='Height (U)'
+ verbose_name=_('height (U)')
)
is_full_depth = models.BooleanField(
default=True,
- verbose_name='Is full depth',
+ verbose_name=_('is full depth'),
help_text=_('Device consumes both front and rear rack faces')
)
subdevice_role = models.CharField(
max_length=50,
choices=SubdeviceRoleChoices,
blank=True,
- verbose_name='Parent/child status',
+ verbose_name=_('parent/child status'),
help_text=_('Parent devices house child devices in device bays. Leave blank '
'if this device type is neither a parent nor a child.')
)
airflow = models.CharField(
+ verbose_name=_('airflow'),
max_length=50,
choices=DeviceAirflowChoices,
blank=True
@@ -128,12 +133,51 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True
)
- images = GenericRelation(
- to='extras.ImageAttachment'
+ # Counter fields
+ console_port_template_count = CounterCacheField(
+ to_model='dcim.ConsolePortTemplate',
+ to_field='device_type'
+ )
+ console_server_port_template_count = CounterCacheField(
+ to_model='dcim.ConsoleServerPortTemplate',
+ to_field='device_type'
+ )
+ power_port_template_count = CounterCacheField(
+ to_model='dcim.PowerPortTemplate',
+ to_field='device_type'
+ )
+ power_outlet_template_count = CounterCacheField(
+ to_model='dcim.PowerOutletTemplate',
+ to_field='device_type'
+ )
+ interface_template_count = CounterCacheField(
+ to_model='dcim.InterfaceTemplate',
+ to_field='device_type'
+ )
+ front_port_template_count = CounterCacheField(
+ to_model='dcim.FrontPortTemplate',
+ to_field='device_type'
+ )
+ rear_port_template_count = CounterCacheField(
+ to_model='dcim.RearPortTemplate',
+ to_field='device_type'
+ )
+ device_bay_template_count = CounterCacheField(
+ to_model='dcim.DeviceBayTemplate',
+ to_field='device_type'
+ )
+ module_bay_template_count = CounterCacheField(
+ to_model='dcim.ModuleBayTemplate',
+ to_field='device_type'
+ )
+ inventory_item_template_count = CounterCacheField(
+ to_model='dcim.InventoryItemTemplate',
+ to_field='device_type'
)
clone_fields = (
- 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
+ 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
+ 'weight_unit',
)
prerequisite_models = (
'dcim.Manufacturer',
@@ -151,6 +195,8 @@ class DeviceType(PrimaryModel, WeightMixin):
name='%(app_label)s_%(class)s_unique_manufacturer_slug'
),
)
+ verbose_name = _('device type')
+ verbose_name_plural = _('device types')
def __str__(self):
return self.model
@@ -234,7 +280,7 @@ class DeviceType(PrimaryModel, WeightMixin):
# U height must be divisible by 0.5
if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
raise ValidationError({
- 'u_height': "U height must be in increments of 0.5 rack units."
+ 'u_height': _("U height must be in increments of 0.5 rack units.")
})
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
@@ -250,8 +296,8 @@ class DeviceType(PrimaryModel, WeightMixin):
)
if d.position not in u_available:
raise ValidationError({
- 'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
- "{}U".format(d, d.rack, self.u_height)
+ 'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
+ "{}U").format(d, d.rack, self.u_height)
})
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
@@ -263,23 +309,23 @@ class DeviceType(PrimaryModel, WeightMixin):
if racked_instance_count:
url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
raise ValidationError({
- 'u_height': mark_safe(
- f'Unable to set 0U height: Found {racked_instance_count} instances already '
- f'mounted within racks.'
- )
+ 'u_height': mark_safe(_(
+ 'Unable to set 0U height: Found {racked_instance_count} instances already '
+ 'mounted within racks.'
+ ).format(url=url, racked_instance_count=racked_instance_count))
})
if (
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
) and self.pk and self.devicebaytemplates.count():
raise ValidationError({
- 'subdevice_role': "Must delete all device bay templates associated with this device before "
- "declassifying it as a parent device."
+ 'subdevice_role': _("Must delete all device bay templates associated with this device before "
+ "declassifying it as a parent device.")
})
if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
raise ValidationError({
- 'u_height': "Child device types must be 0U."
+ 'u_height': _("Child device types must be 0U.")
})
def save(self, *args, **kwargs):
@@ -311,7 +357,7 @@ class DeviceType(PrimaryModel, WeightMixin):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
-class ModuleType(PrimaryModel, WeightMixin):
+class ModuleType(ImageAttachmentsMixin, 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
@@ -324,19 +370,16 @@ class ModuleType(PrimaryModel, WeightMixin):
related_name='module_types'
)
model = models.CharField(
+ verbose_name=_('model'),
max_length=100
)
part_number = models.CharField(
+ verbose_name=_('part number'),
max_length=50,
blank=True,
help_text=_('Discrete part number (optional)')
)
- # Generic relations
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
-
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
prerequisite_models = (
'dcim.Manufacturer',
@@ -350,6 +393,8 @@ class ModuleType(PrimaryModel, WeightMixin):
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
)
+ verbose_name = _('module type')
+ verbose_name_plural = _('module types')
def __str__(self):
return self.model
@@ -411,11 +456,12 @@ class DeviceRole(OrganizationalModel):
virtual machines as well.
"""
color = ColorField(
+ verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
vm_role = models.BooleanField(
default=True,
- verbose_name='VM Role',
+ verbose_name=_('VM role'),
help_text=_('Virtual machines may be assigned to this role')
)
config_template = models.ForeignKey(
@@ -426,15 +472,19 @@ class DeviceRole(OrganizationalModel):
null=True
)
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('device role')
+ verbose_name_plural = _('device roles')
+
def get_absolute_url(self):
return reverse('dcim:devicerole', args=[self.pk])
class Platform(OrganizationalModel):
"""
- Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
- NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
- specifying a NAPALM driver.
+ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
+ Platform may optionally be associated with a particular Manufacturer.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
@@ -451,18 +501,11 @@ class Platform(OrganizationalModel):
blank=True,
null=True
)
- napalm_driver = models.CharField(
- max_length=50,
- blank=True,
- verbose_name='NAPALM driver',
- help_text=_('The name of the NAPALM driver to use when interacting with devices')
- )
- napalm_args = models.JSONField(
- blank=True,
- null=True,
- verbose_name='NAPALM arguments',
- help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
- )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('platform')
+ verbose_name_plural = _('platforms')
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
@@ -482,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
-class Device(PrimaryModel, ConfigContextModel):
+class Device(
+ ContactsMixin,
+ ImageAttachmentsMixin,
+ RenderConfigMixin,
+ ConfigContextModel,
+ TrackingModelMixin,
+ PrimaryModel
+):
"""
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.
@@ -499,7 +549,7 @@ class Device(PrimaryModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='instances'
)
- device_role = models.ForeignKey(
+ role = models.ForeignKey(
to='dcim.DeviceRole',
on_delete=models.PROTECT,
related_name='devices',
@@ -520,6 +570,7 @@ class Device(PrimaryModel, ConfigContextModel):
null=True
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=64,
blank=True,
null=True
@@ -533,7 +584,7 @@ class Device(PrimaryModel, ConfigContextModel):
serial = models.CharField(
max_length=50,
blank=True,
- verbose_name='Serial number',
+ verbose_name=_('serial number'),
help_text=_("Chassis serial number, assigned by the manufacturer")
)
asset_tag = models.CharField(
@@ -541,7 +592,7 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True,
null=True,
unique=True,
- verbose_name='Asset tag',
+ verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this device')
)
site = models.ForeignKey(
@@ -569,21 +620,23 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
- verbose_name='Position (U)',
+ verbose_name=_('position (U)'),
help_text=_('The lowest-numbered unit occupied by the device')
)
face = models.CharField(
max_length=50,
blank=True,
choices=DeviceFaceChoices,
- verbose_name='Rack face'
+ verbose_name=_('rack face')
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=DeviceStatusChoices,
default=DeviceStatusChoices.STATUS_ACTIVE
)
airflow = models.CharField(
+ verbose_name=_('airflow'),
max_length=50,
choices=DeviceAirflowChoices,
blank=True
@@ -594,7 +647,7 @@ class Device(PrimaryModel, ConfigContextModel):
related_name='+',
blank=True,
null=True,
- verbose_name='Primary IPv4'
+ verbose_name=_('primary IPv4')
)
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
@@ -602,7 +655,15 @@ class Device(PrimaryModel, ConfigContextModel):
related_name='+',
blank=True,
null=True,
- verbose_name='Primary IPv6'
+ verbose_name=_('primary IPv6')
+ )
+ oob_ip = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True,
+ verbose_name=_('out-of-band IP')
)
cluster = models.ForeignKey(
to='virtualization.Cluster',
@@ -619,37 +680,82 @@ class Device(PrimaryModel, ConfigContextModel):
null=True
)
vc_position = models.PositiveSmallIntegerField(
+ verbose_name=_('VC position'),
blank=True,
null=True,
validators=[MaxValueValidator(255)],
help_text=_('Virtual chassis position')
)
vc_priority = models.PositiveSmallIntegerField(
+ verbose_name=_('VC priority'),
blank=True,
null=True,
validators=[MaxValueValidator(255)],
help_text=_('Virtual chassis master election priority')
)
- config_template = models.ForeignKey(
- to='extras.ConfigTemplate',
- on_delete=models.PROTECT,
- related_name='devices',
+ latitude = models.DecimalField(
+ verbose_name=_('latitude'),
+ max_digits=8,
+ decimal_places=6,
blank=True,
- null=True
+ null=True,
+ help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
+ )
+ longitude = models.DecimalField(
+ verbose_name=_('longitude'),
+ max_digits=9,
+ decimal_places=6,
+ blank=True,
+ null=True,
+ help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
- # Generic relations
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
+ # Counter fields
+ console_port_count = CounterCacheField(
+ to_model='dcim.ConsolePort',
+ to_field='device'
)
- images = GenericRelation(
- to='extras.ImageAttachment'
+ console_server_port_count = CounterCacheField(
+ to_model='dcim.ConsoleServerPort',
+ to_field='device'
+ )
+ power_port_count = CounterCacheField(
+ to_model='dcim.PowerPort',
+ to_field='device'
+ )
+ power_outlet_count = CounterCacheField(
+ to_model='dcim.PowerOutlet',
+ to_field='device'
+ )
+ interface_count = CounterCacheField(
+ to_model='dcim.Interface',
+ to_field='device'
+ )
+ front_port_count = CounterCacheField(
+ to_model='dcim.FrontPort',
+ to_field='device'
+ )
+ rear_port_count = CounterCacheField(
+ to_model='dcim.RearPort',
+ to_field='device'
+ )
+ device_bay_count = CounterCacheField(
+ to_model='dcim.DeviceBay',
+ to_field='device'
+ )
+ module_bay_count = CounterCacheField(
+ to_model='dcim.ModuleBay',
+ to_field='device'
+ )
+ inventory_item_count = CounterCacheField(
+ to_model='dcim.InventoryItem',
+ to_field='device'
)
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = (
- 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
+ 'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'cluster', 'virtual_chassis',
)
prerequisite_models = (
@@ -669,7 +775,7 @@ class Device(PrimaryModel, ConfigContextModel):
Lower('name'), 'site',
name='%(app_label)s_%(class)s_unique_name_site',
condition=Q(tenant__isnull=True),
- violation_error_message="Device name must be unique per site."
+ violation_error_message=_("Device name must be unique per site.")
),
models.UniqueConstraint(
fields=('rack', 'position', 'face'),
@@ -680,6 +786,8 @@ class Device(PrimaryModel, ConfigContextModel):
name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
),
)
+ verbose_name = _('device')
+ verbose_name_plural = _('devices')
def __str__(self):
if self.name and self.asset_tag:
@@ -699,48 +807,68 @@ class Device(PrimaryModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
+ @property
+ def device_role(self):
+ """
+ For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
+ """
+ return self.role
+
+ @device_role.setter
+ def device_role(self, value):
+ """
+ For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
+ """
+ self.role = value
+
def clean(self):
super().clean()
# Validate site/location/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
- 'rack': f"Rack {self.rack} does not belong to site {self.site}.",
+ 'rack': _("Rack {rack} does not belong to site {site}.").format(rack=self.rack, site=self.site),
})
if self.location and self.site != self.location.site:
raise ValidationError({
- 'location': f"Location {self.location} does not belong to site {self.site}.",
+ 'location': _(
+ "Location {location} does not belong to site {site}."
+ ).format(location=self.location, site=self.site)
})
if self.rack and self.location and self.rack.location != self.location:
raise ValidationError({
- 'rack': f"Rack {self.rack} does not belong to location {self.location}.",
+ 'rack': _(
+ "Rack {rack} does not belong to location {location}."
+ ).format(rack=self.rack, location=self.location)
})
if self.rack is None:
if self.face:
raise ValidationError({
- 'face': "Cannot select a rack face without assigning a rack.",
+ 'face': _("Cannot select a rack face without assigning a rack."),
})
if self.position:
raise ValidationError({
- 'position': "Cannot select a rack position without assigning a rack.",
+ 'position': _("Cannot select a rack position without assigning a rack."),
})
# Validate rack position and face
if self.position and self.position % decimal.Decimal(0.5):
raise ValidationError({
- 'position': "Position must be in increments of 0.5 rack units."
+ 'position': _("Position must be in increments of 0.5 rack units.")
})
if self.position and not self.face:
raise ValidationError({
- 'face': "Must specify rack face when defining rack position.",
+ 'face': _("Must specify rack face when defining rack position."),
})
# Prevent 0U devices from being assigned to a specific position
if hasattr(self, 'device_type'):
if self.position and self.device_type.u_height == 0:
raise ValidationError({
- 'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
+ 'position': _(
+ "A U0 device type ({device_type}) cannot be assigned to a rack position."
+ ).format(device_type=self.device_type)
})
if self.rack:
@@ -749,13 +877,17 @@ class Device(PrimaryModel, ConfigContextModel):
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face:
raise ValidationError({
- 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
- "parent device."
+ 'face': _(
+ "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
+ "device."
+ )
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
- 'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
- "the parent device."
+ 'position': _(
+ "Child device types cannot be assigned to a rack position. This is an attribute of the "
+ "parent device."
+ )
})
# Validate rack space
@@ -766,19 +898,23 @@ class Device(PrimaryModel, ConfigContextModel):
)
if self.position and self.position not in available_units:
raise ValidationError({
- 'position': f"U{self.position} is already occupied or does not have sufficient space to "
- f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
+ 'position': _(
+ "U{position} is already occupied or does not have sufficient space to accommodate this "
+ "device type: {device_type} ({u_height}U)"
+ ).format(
+ position=self.position, device_type=self.device_type, u_height=self.device_type.u_height
+ )
})
except DeviceType.DoesNotExist:
pass
- # Validate primary IP addresses
+ # Validate primary & OOB IP addresses
vc_interfaces = self.vc_interfaces(if_master=False)
if self.primary_ip4:
if self.primary_ip4.family != 4:
raise ValidationError({
- 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
+ 'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
})
if self.primary_ip4.assigned_object in vc_interfaces:
pass
@@ -786,12 +922,14 @@ class Device(PrimaryModel, ConfigContextModel):
pass
else:
raise ValidationError({
- 'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
+ 'primary_ip4': _(
+ "The specified IP address ({primary_ip4}) is not assigned to this device."
+ ).format(primary_ip4=self.primary_ip4)
})
if self.primary_ip6:
if self.primary_ip6.family != 6:
raise ValidationError({
- 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
+ 'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
})
if self.primary_ip6.assigned_object in vc_interfaces:
pass
@@ -799,27 +937,43 @@ class Device(PrimaryModel, ConfigContextModel):
pass
else:
raise ValidationError({
- 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
+ 'primary_ip6': _(
+ "The specified IP address ({primary_ip6}) is not assigned to this device."
+ ).format(primary_ip6=self.primary_ip6)
+ })
+ if self.oob_ip:
+ if self.oob_ip.assigned_object in vc_interfaces:
+ pass
+ elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
+ pass
+ else:
+ raise ValidationError({
+ 'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
})
# Validate manufacturer/platform
if hasattr(self, 'device_type') and self.platform:
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
raise ValidationError({
- 'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
- f"this device's type belongs to {self.device_type.manufacturer}."
+ 'platform': _(
+ "The assigned platform is limited to {platform_manufacturer} device types, but this device's "
+ "type belongs to {device_type_manufacturer}."
+ ).format(
+ platform_manufacturer=self.platform.manufacturer,
+ device_type_manufacturer=self.device_type.manufacturer
+ )
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
raise ValidationError({
- 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
+ 'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
})
# Validate virtual chassis assignment
if self.virtual_chassis and self.vc_position is None:
raise ValidationError({
- 'vc_position': "A device assigned to a virtual chassis must have its position defined."
+ 'vc_position': _("A device assigned to a virtual chassis must have its position defined.")
})
def _instantiate_components(self, queryset, bulk_create=True):
@@ -916,17 +1070,6 @@ class Device(PrimaryModel, ConfigContextModel):
def interfaces_count(self):
return self.vc_interfaces().count()
- def get_config_template(self):
- """
- Return the appropriate ConfigTemplate (if any) for this Device.
- """
- if self.config_template:
- return self.config_template
- if self.device_role.config_template:
- return self.device_role.config_template
- if self.platform and self.platform.config_template:
- return self.platform.config_template
-
def get_vc_master(self):
"""
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
@@ -1004,6 +1147,7 @@ class Module(PrimaryModel, ConfigContextModel):
related_name='instances'
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=ModuleStatusChoices,
default=ModuleStatusChoices.STATUS_ACTIVE
@@ -1011,14 +1155,14 @@ class Module(PrimaryModel, ConfigContextModel):
serial = models.CharField(
max_length=50,
blank=True,
- verbose_name='Serial number'
+ verbose_name=_('serial number')
)
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
- verbose_name='Asset tag',
+ verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this device')
)
@@ -1026,6 +1170,8 @@ class Module(PrimaryModel, ConfigContextModel):
class Meta:
ordering = ('module_bay',)
+ verbose_name = _('module')
+ verbose_name_plural = _('modules')
def __str__(self):
return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
@@ -1041,7 +1187,9 @@ class Module(PrimaryModel, ConfigContextModel):
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
raise ValidationError(
- f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
+ _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
+ device=self.device
+ )
)
def save(self, *args, **kwargs):
@@ -1139,16 +1287,25 @@ class VirtualChassis(PrimaryModel):
null=True
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=64
)
domain = models.CharField(
+ verbose_name=_('domain'),
max_length=30,
blank=True
)
+ # Counter fields
+ member_count = CounterCacheField(
+ to_model='dcim.Device',
+ to_field='virtual_chassis'
+ )
+
class Meta:
ordering = ['name']
- verbose_name_plural = 'virtual chassis'
+ verbose_name = _('virtual chassis')
+ verbose_name_plural = _('virtual chassis')
def __str__(self):
return self.name
@@ -1163,7 +1320,9 @@ class VirtualChassis(PrimaryModel):
# VirtualChassis.)
if self.pk and self.master and self.master not in self.members.all():
raise ValidationError({
- 'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
+ 'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format(
+ master=self.master
+ )
})
def delete(self, *args, **kwargs):
@@ -1176,10 +1335,10 @@ class VirtualChassis(PrimaryModel):
lag__device=F('device')
)
if interfaces:
- raise ProtectedError(
- f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
- interfaces
- )
+ raise ProtectedError(_(
+ "Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG "
+ "interfaces."
+ ).format(self=self, interfaces=InterfaceSpeedChoices))
return super().delete(*args, **kwargs)
@@ -1193,14 +1352,17 @@ class VirtualDeviceContext(PrimaryModel):
null=True
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=64
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=VirtualDeviceContextStatusChoices,
)
identifier = models.PositiveSmallIntegerField(
- help_text='Numeric identifier unique to the parent device',
+ verbose_name=_('identifier'),
+ help_text=_('Numeric identifier unique to the parent device'),
blank=True,
null=True,
)
@@ -1210,7 +1372,7 @@ class VirtualDeviceContext(PrimaryModel):
related_name='+',
blank=True,
null=True,
- verbose_name='Primary IPv4'
+ verbose_name=_('primary IPv4')
)
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
@@ -1218,7 +1380,7 @@ class VirtualDeviceContext(PrimaryModel):
related_name='+',
blank=True,
null=True,
- verbose_name='Primary IPv6'
+ verbose_name=_('primary IPv6')
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -1228,6 +1390,7 @@ class VirtualDeviceContext(PrimaryModel):
null=True
)
comments = models.TextField(
+ verbose_name=_('comments'),
blank=True
)
@@ -1243,6 +1406,8 @@ class VirtualDeviceContext(PrimaryModel):
name='%(app_label)s_%(class)s_device_name'
),
)
+ verbose_name = _('virtual device context')
+ verbose_name_plural = _('virtual device contexts')
def __str__(self):
return self.name
@@ -1273,7 +1438,9 @@ class VirtualDeviceContext(PrimaryModel):
continue
if primary_ip.family != family:
raise ValidationError({
- f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
+ f'primary_ip{family}': _(
+ "{primary_ip} is not an IPv{family} address."
+ ).format(family=family, primary_ip=primary_ip)
})
device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces:
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index 486945b0f..95f6d41fe 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -1,17 +1,25 @@
from django.core.exceptions import ValidationError
from django.db import models
+from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from utilities.utils import to_grams
+__all__ = (
+ 'RenderConfigMixin',
+ 'WeightMixin',
+)
+
class WeightMixin(models.Model):
weight = models.DecimalField(
+ verbose_name=_('weight'),
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
weight_unit = models.CharField(
+ verbose_name=_('weight unit'),
max_length=50,
choices=WeightUnitChoices,
blank=True,
@@ -40,4 +48,28 @@ class WeightMixin(models.Model):
# Validate weight and weight_unit
if self.weight and not self.weight_unit:
- raise ValidationError("Must specify a unit when setting a weight")
+ raise ValidationError(_("Must specify a unit when setting a weight"))
+
+
+class RenderConfigMixin(models.Model):
+ config_template = models.ForeignKey(
+ to='extras.ConfigTemplate',
+ on_delete=models.PROTECT,
+ related_name='%(class)ss',
+ blank=True,
+ null=True
+ )
+
+ class Meta:
+ abstract = True
+
+ def get_config_template(self):
+ """
+ Return the appropriate ConfigTemplate (if any) for this Device.
+ """
+ if self.config_template:
+ return self.config_template
+ if self.role.config_template:
+ return self.role.config_template
+ if self.platform and self.platform.config_template:
+ return self.platform.config_template
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 3377a9edb..83e5eb23a 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -1,13 +1,13 @@
-from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from netbox.config import ConfigItem
from netbox.models import PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.validators import ExclusionValidator
from .device_components import CabledObjectModel, PathEndpoint
@@ -21,7 +21,7 @@ __all__ = (
# Power
#
-class PowerPanel(PrimaryModel):
+class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
"""
@@ -36,17 +36,10 @@ class PowerPanel(PrimaryModel):
null=True
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=100
)
- # Generic relations
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
-
prerequisite_models = (
'dcim.Site',
)
@@ -59,6 +52,8 @@ class PowerPanel(PrimaryModel):
name='%(app_label)s_%(class)s_unique_site_name'
),
)
+ verbose_name = _('power panel')
+ verbose_name_plural = _('power panels')
def __str__(self):
return self.name
@@ -72,7 +67,8 @@ class PowerPanel(PrimaryModel):
# Location must belong to assigned Site
if self.location and self.location.site != self.site:
raise ValidationError(
- f"Location {self.location} ({self.location.site}) is in a different site than {self.site}"
+ _("Location {location} ({location_site}) is in a different site than {site}").format(
+ location=self.location, location_site=self.location.site, site=self.site)
)
@@ -92,49 +88,65 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
null=True
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=100
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=PowerFeedStatusChoices,
default=PowerFeedStatusChoices.STATUS_ACTIVE
)
type = models.CharField(
+ verbose_name=_('type'),
max_length=50,
choices=PowerFeedTypeChoices,
default=PowerFeedTypeChoices.TYPE_PRIMARY
)
supply = models.CharField(
+ verbose_name=_('supply'),
max_length=50,
choices=PowerFeedSupplyChoices,
default=PowerFeedSupplyChoices.SUPPLY_AC
)
phase = models.CharField(
+ verbose_name=_('phase'),
max_length=50,
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
voltage = models.SmallIntegerField(
+ verbose_name=_('voltage'),
default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
validators=[ExclusionValidator([0])]
)
amperage = models.PositiveSmallIntegerField(
+ verbose_name=_('amperage'),
validators=[MinValueValidator(1)],
default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
)
max_utilization = models.PositiveSmallIntegerField(
+ verbose_name=_('max utilization'),
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
help_text=_("Maximum permissible draw (percentage)")
)
available_power = models.PositiveIntegerField(
+ verbose_name=_('available power'),
default=0,
editable=False
)
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='power_feeds',
+ blank=True,
+ null=True
+ )
clone_fields = (
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
- 'max_utilization',
+ 'max_utilization', 'tenant',
)
prerequisite_models = (
'dcim.PowerPanel',
@@ -148,6 +160,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
name='%(app_label)s_%(class)s_unique_power_panel_name'
),
)
+ verbose_name = _('power feed')
+ verbose_name_plural = _('power feeds')
def __str__(self):
return self.name
@@ -160,14 +174,14 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
- raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
+ raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
self.rack, self.rack.site, self.power_panel, self.power_panel.site
))
# AC voltage cannot be negative
if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
raise ValidationError({
- "voltage": "Voltage cannot be negative for AC supply"
+ "voltage": _("Voltage cannot be negative for AC supply")
})
def save(self, *args, **kwargs):
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index d73c8e27b..ef0dde4da 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -1,7 +1,7 @@
import decimal
from functools import cached_property
-from django.contrib.auth.models import User
+from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
@@ -9,12 +9,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count
from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange, to_grams
@@ -39,19 +40,26 @@ class RackRole(OrganizationalModel):
Racks can be organized by functional role, similar to Devices.
"""
color = ColorField(
+ verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('rack role')
+ verbose_name_plural = _('rack roles')
+
def get_absolute_url(self):
return reverse('dcim:rackrole', args=[self.pk])
-class Rack(PrimaryModel, WeightMixin):
+class Rack(ContactsMixin, ImageAttachmentsMixin, 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.
"""
name = models.CharField(
+ verbose_name=_('name'),
max_length=100
)
_name = NaturalOrderingField(
@@ -63,7 +71,7 @@ class Rack(PrimaryModel, WeightMixin):
max_length=50,
blank=True,
null=True,
- verbose_name='Facility ID',
+ verbose_name=_('facility ID'),
help_text=_("Locally-assigned identifier")
)
site = models.ForeignKey(
@@ -86,6 +94,7 @@ class Rack(PrimaryModel, WeightMixin):
null=True
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=RackStatusChoices,
default=RackStatusChoices.STATUS_ACTIVE
@@ -101,55 +110,64 @@ class Rack(PrimaryModel, WeightMixin):
serial = models.CharField(
max_length=50,
blank=True,
- verbose_name='Serial number'
+ verbose_name=_('serial number')
)
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
- verbose_name='Asset tag',
+ verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this rack')
)
type = models.CharField(
choices=RackTypeChoices,
max_length=50,
blank=True,
- verbose_name='Type'
+ verbose_name=_('type')
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
- verbose_name='Width',
+ verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
- verbose_name='Height (U)',
+ verbose_name=_('height (U)'),
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
+ starting_unit = models.PositiveSmallIntegerField(
+ default=RACK_STARTING_UNIT_DEFAULT,
+ verbose_name=_('starting unit'),
+ help_text=_('Starting unit for rack')
+ )
desc_units = models.BooleanField(
default=False,
- verbose_name='Descending units',
+ verbose_name=_('descending units'),
help_text=_('Units are numbered top-to-bottom')
)
outer_width = models.PositiveSmallIntegerField(
+ verbose_name=_('outer width'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
+ verbose_name=_('outer depth'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
+ verbose_name=_('outer unit'),
max_length=50,
choices=RackDimensionUnitChoices,
blank=True,
)
max_weight = models.PositiveIntegerField(
+ verbose_name=_('max weight'),
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
@@ -160,6 +178,7 @@ class Rack(PrimaryModel, WeightMixin):
null=True
)
mounting_depth = models.PositiveSmallIntegerField(
+ verbose_name=_('mounting depth'),
blank=True,
null=True,
help_text=(
@@ -175,12 +194,6 @@ class Rack(PrimaryModel, WeightMixin):
object_id_field='scope_id',
related_query_name='rack'
)
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
@@ -203,6 +216,8 @@ class Rack(PrimaryModel, WeightMixin):
name='%(app_label)s_%(class)s_unique_location_facility_id'
),
)
+ verbose_name = _('rack')
+ verbose_name_plural = _('racks')
def __str__(self):
if self.facility_id:
@@ -217,36 +232,40 @@ class Rack(PrimaryModel, WeightMixin):
# Validate location/site assignment
if self.site and self.location and self.location.site != self.site:
- raise ValidationError(f"Assigned location must belong to parent site ({self.site}).")
+ raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
- raise ValidationError("Must specify a unit when setting an outer width/depth")
+ raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
- raise ValidationError("Must specify a unit when setting a maximum weight")
+ raise ValidationError(_("Must specify a unit when setting a maximum weight"))
if self.pk:
- # Validate that Rack is tall enough to house the installed Devices
- top_device = Device.objects.filter(
- rack=self
- ).exclude(
- position__isnull=True
- ).order_by('-position').first()
- if top_device:
- min_height = top_device.position + top_device.device_type.u_height - 1
+ mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
+
+ # Validate that Rack is tall enough to house the highest mounted Device
+ if top_device := mounted_devices.last():
+ min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
if self.u_height < min_height:
raise ValidationError({
- 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
- min_height
- )
+ 'u_height': _("Rack must be at least {min_height}U tall to house currently installed devices.").format(min_height=min_height)
})
+
+ # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
+ if last_device := mounted_devices.first():
+ if self.starting_unit > last_device.position:
+ raise ValidationError({
+ 'starting_unit': _("Rack unit numbering must begin at {position} or less to house "
+ "currently installed devices.").format(position=last_device.position)
+ })
+
# Validate that Rack was assigned a Location of its same site, if applicable
if self.location:
if self.location.site != self.site:
raise ValidationError({
- 'location': f"Location must be from the same site, {self.site}."
+ 'location': _("Location must be from the same site, {site}.").format(site=self.site)
})
def save(self, *args, **kwargs):
@@ -269,8 +288,8 @@ class Rack(PrimaryModel, WeightMixin):
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
- return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
- return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
+ return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
+ return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
def get_status_color(self):
return RackStatusChoices.colors.get(self.status)
@@ -306,7 +325,7 @@ class Rack(PrimaryModel, WeightMixin):
devices = Device.objects.prefetch_related(
'device_type',
'device_type__manufacturer',
- 'device_role'
+ 'role'
).annotate(
devicebay_count=Count('devicebays')
).exclude(
@@ -495,6 +514,7 @@ class RackReservation(PrimaryModel):
related_name='reservations'
)
units = ArrayField(
+ verbose_name=_('units'),
base_field=models.PositiveSmallIntegerField()
)
tenant = models.ForeignKey(
@@ -505,10 +525,11 @@ class RackReservation(PrimaryModel):
null=True
)
user = models.ForeignKey(
- to=User,
+ to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200
)
@@ -519,6 +540,8 @@ class RackReservation(PrimaryModel):
class Meta:
ordering = ['created', 'pk']
+ verbose_name = _('rack reservation')
+ verbose_name_plural = _('rack reservations')
def __str__(self):
return "Reservation for rack {}".format(self.rack)
@@ -535,7 +558,7 @@ class RackReservation(PrimaryModel):
invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units:
raise ValidationError({
- 'units': "Invalid unit(s) for {}U rack: {}".format(
+ 'units': _("Invalid unit(s) for {}U rack: {}").format(
self.rack.u_height,
', '.join([str(u) for u in invalid_units]),
),
@@ -548,7 +571,7 @@ class RackReservation(PrimaryModel):
conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units:
raise ValidationError({
- 'units': 'The following units have already been reserved: {}'.format(
+ 'units': _('The following units have already been reserved: {}').format(
', '.join([str(u) for u in conflicting_units]),
)
})
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 3bd434648..d2797bf95 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -2,12 +2,13 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from netbox.models import NestedGroupModel, PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import NaturalOrderingField
__all__ = (
@@ -22,22 +23,18 @@ __all__ = (
# Regions
#
-class Region(NestedGroupModel):
+class Region(ContactsMixin, NestedGroupModel):
"""
A region represents a geographic collection of sites. For example, you might create regions representing countries,
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).
"""
- # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='region'
)
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
class Meta:
constraints = (
@@ -49,7 +46,7 @@ class Region(NestedGroupModel):
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
- violation_error_message="A top-level region with this name already exists."
+ violation_error_message=_("A top-level region with this name already exists.")
),
models.UniqueConstraint(
fields=('parent', 'slug'),
@@ -59,9 +56,11 @@ class Region(NestedGroupModel):
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
- violation_error_message="A top-level region with this slug already exists."
+ violation_error_message=_("A top-level region with this slug already exists.")
),
)
+ verbose_name = _('region')
+ verbose_name_plural = _('regions')
def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk])
@@ -77,22 +76,18 @@ class Region(NestedGroupModel):
# Site groups
#
-class SiteGroup(NestedGroupModel):
+class SiteGroup(ContactsMixin, NestedGroupModel):
"""
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
nested recursively to form a hierarchy.
"""
- # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site_group'
)
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
class Meta:
constraints = (
@@ -104,7 +99,7 @@ class SiteGroup(NestedGroupModel):
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
- violation_error_message="A top-level site group with this name already exists."
+ violation_error_message=_("A top-level site group with this name already exists.")
),
models.UniqueConstraint(
fields=('parent', 'slug'),
@@ -114,9 +109,11 @@ class SiteGroup(NestedGroupModel):
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
- violation_error_message="A top-level site group with this slug already exists."
+ violation_error_message=_("A top-level site group with this slug already exists.")
),
)
+ verbose_name = _('site group')
+ verbose_name_plural = _('site groups')
def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk])
@@ -132,12 +129,13 @@ class SiteGroup(NestedGroupModel):
# Sites
#
-class Site(PrimaryModel):
+class Site(ContactsMixin, ImageAttachmentsMixin, 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).
"""
name = models.CharField(
+ verbose_name=_('name'),
max_length=100,
unique=True,
help_text=_("Full name of the site")
@@ -148,10 +146,12 @@ class Site(PrimaryModel):
blank=True
)
slug = models.SlugField(
+ verbose_name=_('slug'),
max_length=100,
unique=True
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=SiteStatusChoices,
default=SiteStatusChoices.STATUS_ACTIVE
@@ -178,9 +178,10 @@ class Site(PrimaryModel):
null=True
)
facility = models.CharField(
+ verbose_name=_('facility'),
max_length=50,
blank=True,
- help_text=_("Local facility ID or description")
+ help_text=_('Local facility ID or description')
)
asns = models.ManyToManyField(
to='ipam.ASN',
@@ -191,28 +192,32 @@ class Site(PrimaryModel):
blank=True
)
physical_address = models.CharField(
+ verbose_name=_('physical address'),
max_length=200,
blank=True,
- help_text=_("Physical location of the building")
+ help_text=_('Physical location of the building')
)
shipping_address = models.CharField(
+ verbose_name=_('shipping address'),
max_length=200,
blank=True,
- help_text=_("If different from the physical address")
+ help_text=_('If different from the physical address')
)
latitude = models.DecimalField(
+ verbose_name=_('latitude'),
max_digits=8,
decimal_places=6,
blank=True,
null=True,
- help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
+ help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)
longitude = models.DecimalField(
+ verbose_name=_('longitude'),
max_digits=9,
decimal_places=6,
blank=True,
null=True,
- help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
+ help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)
# Generic relations
@@ -222,12 +227,6 @@ class Site(PrimaryModel):
object_id_field='scope_id',
related_query_name='site'
)
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
clone_fields = (
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
@@ -236,6 +235,8 @@ class Site(PrimaryModel):
class Meta:
ordering = ('_name',)
+ verbose_name = _('site')
+ verbose_name_plural = _('sites')
def __str__(self):
return self.name
@@ -251,7 +252,7 @@ class Site(PrimaryModel):
# Locations
#
-class Location(NestedGroupModel):
+class Location(ContactsMixin, ImageAttachmentsMixin, 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.
@@ -262,6 +263,7 @@ class Location(NestedGroupModel):
related_name='locations'
)
status = models.CharField(
+ verbose_name=_('status'),
max_length=50,
choices=LocationStatusChoices,
default=LocationStatusChoices.STATUS_ACTIVE
@@ -281,12 +283,6 @@ class Location(NestedGroupModel):
object_id_field='scope_id',
related_query_name='location'
)
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
prerequisite_models = (
@@ -304,7 +300,7 @@ class Location(NestedGroupModel):
fields=('site', 'name'),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
- violation_error_message="A location with this name already exists within the specified site."
+ violation_error_message=_("A location with this name already exists within the specified site.")
),
models.UniqueConstraint(
fields=('site', 'parent', 'slug'),
@@ -314,9 +310,11 @@ class Location(NestedGroupModel):
fields=('site', 'slug'),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
- violation_error_message="A location with this slug already exists within the specified site."
+ violation_error_message=_("A location with this slug already exists within the specified site.")
),
)
+ verbose_name = _('location')
+ verbose_name_plural = _('locations')
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
@@ -329,4 +327,6 @@ class Location(NestedGroupModel):
# Parent Location (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
- raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")
+ raise ValidationError(_(
+ "Parent location ({parent}) must belong to the same site ({site})."
+ ).format(parent=self.parent, site=self.site))
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
index bae4f030f..f70c729f4 100644
--- a/netbox/dcim/search.py
+++ b/netbox/dcim/search.py
@@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
- ('napalm_driver', 300),
('description', 500),
)
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 33adef798..9413726fa 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -167,9 +167,9 @@ class CableTraceSVG:
if hasattr(instance, 'parent_object'):
# Termination
return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
- if hasattr(instance, 'device_role'):
+ if hasattr(instance, 'role'):
# Device
- return instance.device_role.color
+ return instance.role.color
else:
# Other parent object
return 'e0e0e0'
diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py
index 9c317ea16..07ea55a33 100644
--- a/netbox/dcim/svg/racks.py
+++ b/netbox/dcim/svg/racks.py
@@ -46,14 +46,14 @@ def get_device_description(device):
Return a description for a device to be rendered in the rack elevation in the following format
Name: ^[A-Z]{3}$
will limit values to exactly three uppercase letters.'
)
)
- choices = ArrayField(
- base_field=models.CharField(max_length=100),
+ choice_set = models.ForeignKey(
+ to='CustomFieldChoiceSet',
+ on_delete=models.PROTECT,
+ related_name='choices_for',
+ verbose_name=_('choice set'),
blank=True,
- null=True,
- help_text=_('Comma-separated list of available choices (for selection fields)')
+ null=True
)
ui_visibility = models.CharField(
max_length=50,
choices=CustomFieldVisibilityChoices,
default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
- verbose_name='UI visibility',
+ verbose_name=_('UI visibility'),
help_text=_('Specifies the visibility of custom field in the UI')
)
is_cloneable = models.BooleanField(
default=False,
- verbose_name='Cloneable',
+ verbose_name=_('is cloneable'),
help_text=_('Replicate this value when cloning objects')
)
@@ -181,12 +196,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
- 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
- 'ui_visibility', 'is_cloneable',
+ 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
+ 'choice_set', 'ui_visibility', 'is_cloneable',
)
class Meta:
ordering = ['group_name', 'weight', 'name']
+ verbose_name = _('custom field')
+ verbose_name_plural = _('custom fields')
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
@@ -208,6 +225,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self):
return SEARCH_TYPES.get(self.type)
+ @property
+ def choices(self):
+ if self.choice_set:
+ return self.choice_set.choices
+ return []
+
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@@ -257,15 +280,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
self.validate(default_value)
except ValidationError as err:
raise ValidationError({
- 'default': f'Invalid default value "{self.default}": {err.message}'
+ 'default': _(
+ 'Invalid default value "{default}": {message}'
+ ).format(default=self.default, message=self.message)
})
# Minimum/maximum values can 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"})
+ 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"})
+ 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 = (
@@ -275,42 +300,42 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
)
if self.validation_regex and self.type not in regex_types:
raise ValidationError({
- 'validation_regex': "Regular expression validation is supported only for text and URL fields"
+ 'validation_regex': _("Regular expression validation is supported only for text and URL fields")
})
- # Choices can be set only on selection fields
- if self.choices and self.type not in (
- CustomFieldTypeChoices.TYPE_SELECT,
- CustomFieldTypeChoices.TYPE_MULTISELECT
- ):
- raise ValidationError({
- 'choices': "Choices may be set only for custom selection fields."
- })
-
- # Selection fields must have at least one choice defined
+ # Choice set must be set on selection fields, and *only* on selection fields
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
- ) and not self.choices:
+ ):
+ if not self.choice_set:
+ raise ValidationError({
+ 'choice_set': _("Selection fields must specify a set of choices.")
+ })
+ elif self.choice_set:
raise ValidationError({
- 'choices': "Selection fields must specify at least one choice."
+ 'choice_set': _("Choices may be set only on selection fields.")
})
# A selection field's default (if any) must be present in its available choices
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
raise ValidationError({
- 'default': f"The specified default value ({self.default}) is not listed as an available choice."
+ 'default': _(
+ "The specified default value ({default}) is not listed as an available choice."
+ ).format(default=self.default)
})
# Object fields must define an object_type; other fields must not
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
if not self.object_type:
raise ValidationError({
- 'object_type': "Object fields must define an object type."
+ 'object_type': _("Object fields must define an object type.")
})
elif self.object_type:
raise ValidationError({
- 'object_type': f"{self.get_type_display()} fields may not define an object type."
+ 'object_type': _(
+ "{type_display} fields may not define an object type.")
+ .format(type_display=self.get_type_display())
})
def serialize(self, value):
@@ -389,8 +414,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
- (True, 'True'),
- (False, 'False'),
+ (True, _('True')),
+ (False, _('False')),
)
field = forms.NullBooleanField(
required=required, initial=initial, widget=forms.Select(choices=choices)
@@ -406,7 +431,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Select
elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
- choices = [(c, c) for c in self.choices]
+ choices = self.choice_set.choices
default_choice = self.default if self.default in self.choices else None
if not required or default_choice is None:
@@ -416,12 +441,25 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
if set_initial and default_choice:
initial = default_choice
- if self.type == CustomFieldTypeChoices.TYPE_SELECT:
- field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
+ if for_csv_import:
+ if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+ field_class = CSVChoiceField
+ else:
+ field_class = CSVMultipleChoiceField
field = field_class(choices=choices, required=required, initial=initial)
else:
- field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
- field = field_class(choices=choices, required=required, initial=initial)
+ if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+ field_class = DynamicChoiceField
+ widget_class = APISelect
+ else:
+ field_class = DynamicMultipleChoiceField
+ widget_class = APISelectMultiple
+ field = field_class(
+ choices=choices,
+ required=required,
+ initial=initial,
+ widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
+ )
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
@@ -459,7 +497,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field.validators = [
RegexValidator(
regex=self.validation_regex,
- message=mark_safe(f"Values must match this regex: {self.validation_regex}
")
+ message=mark_safe(_("Values must match this regex: {regex}
").format(
+ regex=self.validation_regex
+ ))
)
]
@@ -472,7 +512,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
field.disabled = True
prepend = 'Name: Value
. Jinja2 template processing is "
- "supported with the same context as the request body (below).")
+ help_text=_(
+ "User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. Headers "
+ "should be defined in the format Name: Value
. Jinja2 template processing is supported with "
+ "the same context as the request body (below)."
+ )
)
body_template = models.TextField(
+ verbose_name=_('body template'),
blank=True,
- help_text=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
- 'included. Available context data includes: event
, model
, '
- 'timestamp
, username
, request_id
, and data
.')
+ help_text=_(
+ "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be "
+ "included. Available context data includes: event
, model
, "
+ "timestamp
, username
, request_id
, and data
."
+ )
)
secret = models.CharField(
+ verbose_name=_('secret'),
max_length=255,
blank=True,
- help_text=_("When provided, the request will include a 'X-Hook-Signature' "
- "header containing a HMAC hex digest of the payload body using "
- "the secret as the key. The secret is not transmitted in "
- "the request.")
+ help_text=_(
+ "When provided, the request will include a X-Hook-Signature
header containing a HMAC hex "
+ "digest of the payload body using the secret as the key. The secret is not transmitted in the request."
+ )
)
conditions = models.JSONField(
+ verbose_name=_('conditions'),
blank=True,
null=True,
help_text=_("A set of conditions which determine whether the webhook will be generated.")
)
ssl_verification = models.BooleanField(
default=True,
- verbose_name='SSL verification',
+ verbose_name=_('SSL verification'),
help_text=_("Enable SSL certificate verification. Disable with caution!")
)
ca_file_path = models.CharField(
max_length=4096,
null=True,
blank=True,
- verbose_name='CA File Path',
- help_text=_('The specific CA certificate file to use for SSL verification. '
- 'Leave blank to use the system defaults.')
+ verbose_name=_('CA File Path'),
+ help_text=_(
+ "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults."
+ )
)
class Meta:
@@ -145,6 +164,8 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
name='%(app_label)s_%(class)s_unique_payload_url_types'
),
)
+ verbose_name = _('webhook')
+ verbose_name_plural = _('webhooks')
def __str__(self):
return self.name
@@ -164,7 +185,7 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
]):
raise ValidationError(
- "At least one event type must be selected: create, update, delete, job_start, and/or job_end."
+ _("At least one event type must be selected: create, update, delete, job_start, and/or job_end.")
)
if self.conditions:
@@ -176,7 +197,7 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
# CA file path requires SSL verification enabled
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
- 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
+ 'ca_file_path': _('Do not specify a CA certificate file if SSL verification is disabled.')
})
def render_headers(self, context):
@@ -219,34 +240,41 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_('The object type(s) to which this link applies.')
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=100,
unique=True
)
enabled = models.BooleanField(
+ verbose_name=_('enabled'),
default=True
)
link_text = models.TextField(
+ verbose_name=_('link text'),
help_text=_("Jinja2 template code for link text")
)
link_url = models.TextField(
- verbose_name='Link URL',
+ verbose_name=_('link URL'),
help_text=_("Jinja2 template code for link URL")
)
weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
default=100
)
group_name = models.CharField(
+ verbose_name=_('group name'),
max_length=50,
blank=True,
help_text=_("Links with the same group will appear as a dropdown menu")
)
button_class = models.CharField(
+ verbose_name=_('button class'),
max_length=30,
choices=CustomLinkButtonClassChoices,
default=CustomLinkButtonClassChoices.DEFAULT,
help_text=_("The class of the first link in a group will be used for the dropdown button")
)
new_window = models.BooleanField(
+ verbose_name=_('new window'),
default=False,
help_text=_("Force link to open in a new window")
)
@@ -257,6 +285,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
class Meta:
ordering = ['group_name', 'weight', 'name']
+ verbose_name = _('custom link')
+ verbose_name_plural = _('custom links')
def __str__(self):
return self.name
@@ -285,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
- link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
+ link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)
@@ -306,28 +336,34 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
help_text=_('The object type(s) to which this template applies.')
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=100
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True
)
template_code = models.TextField(
- help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named '
- 'queryset
.')
+ help_text=_(
+ "Jinja2 template code. The list of objects being exported is passed as a context variable named "
+ "queryset
."
+ )
)
mime_type = models.CharField(
max_length=50,
blank=True,
- verbose_name='MIME type',
+ verbose_name=_('MIME type'),
help_text=_('Defaults to text/plain; charset=utf-8
')
)
file_extension = models.CharField(
+ verbose_name=_('file extension'),
max_length=15,
blank=True,
help_text=_('Extension to append to the rendered filename')
)
as_attachment = models.BooleanField(
+ verbose_name=_('as attachment'),
default=True,
help_text=_("Download file as attachment")
)
@@ -338,6 +374,8 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
class Meta:
ordering = ('name',)
+ verbose_name = _('export template')
+ verbose_name_plural = _('export templates')
def __str__(self):
return self.name
@@ -354,7 +392,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
if self.name.lower() == 'table':
raise ValidationError({
- 'name': f'"{self.name}" is a reserved name. Please choose a different name.'
+ 'name': _('"{name}" is a reserved name. Please choose a different name.').format(name=self.name)
})
def sync_data(self):
@@ -362,6 +400,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Synchronize template content from the designated DataFile (if any).
"""
self.template_code = self.data_file.data_as_string
+ sync_data.alters_data = True
def render(self, queryset):
"""
@@ -406,33 +445,41 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_('The object type(s) to which this filter applies.')
)
name = models.CharField(
+ verbose_name=_('name'),
max_length=100,
unique=True
)
slug = models.SlugField(
+ verbose_name=_('slug'),
max_length=100,
unique=True
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True
)
user = models.ForeignKey(
- to=User,
+ to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True
)
weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
default=100
)
enabled = models.BooleanField(
+ verbose_name=_('enabled'),
default=True
)
shared = models.BooleanField(
+ verbose_name=_('shared'),
default=True
)
- parameters = models.JSONField()
+ parameters = models.JSONField(
+ verbose_name=_('parameters')
+ )
clone_fields = (
'content_types', 'weight', 'enabled', 'parameters',
@@ -440,6 +487,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
class Meta:
ordering = ('weight', 'name')
+ verbose_name = _('saved filter')
+ verbose_name_plural = _('saved filters')
def __str__(self):
return self.name
@@ -457,7 +506,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# 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.'}
+ {'parameters': _('Filter parameters must be stored as a dictionary of keyword arguments.')}
)
@property
@@ -484,9 +533,14 @@ class ImageAttachment(ChangeLoggedModel):
height_field='image_height',
width_field='image_width'
)
- image_height = models.PositiveSmallIntegerField()
- image_width = models.PositiveSmallIntegerField()
+ image_height = models.PositiveSmallIntegerField(
+ verbose_name=_('image height'),
+ )
+ image_width = models.PositiveSmallIntegerField(
+ verbose_name=_('image width'),
+ )
name = models.CharField(
+ verbose_name=_('name'),
max_length=50,
blank=True
)
@@ -497,6 +551,8 @@ class ImageAttachment(ChangeLoggedModel):
class Meta:
ordering = ('name', 'pk') # name may be non-unique
+ verbose_name = _('image attachment')
+ verbose_name_plural = _('image attachments')
def __str__(self):
if self.name:
@@ -558,21 +614,25 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
fk_field='assigned_object_id'
)
created_by = models.ForeignKey(
- to=User,
+ to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True
)
kind = models.CharField(
+ verbose_name=_('kind'),
max_length=30,
choices=JournalEntryKindChoices,
default=JournalEntryKindChoices.KIND_INFO
)
- comments = models.TextField()
+ comments = models.TextField(
+ verbose_name=_('comments'),
+ )
class Meta:
ordering = ('-created',)
- verbose_name_plural = 'journal entries'
+ verbose_name = _('journal entry')
+ verbose_name_plural = _('journal entries')
def __str__(self):
created = timezone.localtime(self.created)
@@ -587,44 +647,102 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
# 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}).")
+ raise ValidationError(
+ _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
+ )
def get_kind_color(self):
return JournalEntryKindChoices.colors.get(self.kind)
+class Bookmark(models.Model):
+ """
+ An object bookmarked by a User.
+ """
+ created = models.DateTimeField(
+ verbose_name=_('created'),
+ auto_now_add=True
+ )
+ object_type = models.ForeignKey(
+ to=ContentType,
+ on_delete=models.PROTECT
+ )
+ object_id = models.PositiveBigIntegerField()
+ object = GenericForeignKey(
+ ct_field='object_type',
+ fk_field='object_id'
+ )
+ user = models.ForeignKey(
+ to=settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ('created', 'pk')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('object_type', 'object_id', 'user'),
+ name='%(app_label)s_%(class)s_unique_per_object_and_user'
+ ),
+ )
+ verbose_name = _('bookmark')
+ verbose_name_plural = _('bookmarks')
+
+ def __str__(self):
+ if self.object:
+ return str(self.object)
+ return super().__str__()
+
+
class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
"""
created = models.DateTimeField(
+ verbose_name=_('created'),
auto_now_add=True
)
comment = models.CharField(
+ verbose_name=_('comment'),
max_length=200,
blank=True
)
data = models.JSONField(
blank=True,
null=True,
- verbose_name='Configuration data'
+ verbose_name=_('configuration data')
)
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ['-created']
+ verbose_name = _('config revision')
+ verbose_name_plural = _('config revisions')
+
def __str__(self):
- return f'Config revision #{self.pk} ({self.created})'
+ if self.is_active:
+ return gettext('Current configuration')
+ return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item):
if item in self.data:
return self.data[item]
return super().__getattribute__(item)
+ def get_absolute_url(self):
+ return reverse('extras:configrevision', args=[self.pk])
+
def activate(self):
"""
Cache the configuration data.
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
+ activate.alters_data = True
- @admin.display(boolean=True)
+ @property
def is_active(self):
return cache.get('config_version') == self.pk
diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py
index f1e336df5..223d679bd 100644
--- a/netbox/extras/models/reports.py
+++ b/netbox/extras/models/reports.py
@@ -4,6 +4,7 @@ from functools import cached_property
from django.db import models
from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
from core.choices import ManagedFileRootPathChoices
from core.models import ManagedFile
@@ -42,6 +43,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
class Meta:
proxy = True
+ verbose_name = _('report module')
+ verbose_name_plural = _('report modules')
def get_absolute_url(self):
return reverse('extras:report_list')
diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py
index de48aae8e..122f56f20 100644
--- a/netbox/extras/models/scripts.py
+++ b/netbox/extras/models/scripts.py
@@ -4,6 +4,7 @@ from functools import cached_property
from django.db import models
from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
from core.choices import ManagedFileRootPathChoices
from core.models import ManagedFile
@@ -42,6 +43,8 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
class Meta:
proxy = True
+ verbose_name = _('script module')
+ verbose_name_plural = _('script modules')
def get_absolute_url(self):
return reverse('extras:script_list')
diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py
index 6d088abb0..debe4c648 100644
--- a/netbox/extras/models/search.py
+++ b/netbox/extras/models/search.py
@@ -2,6 +2,7 @@ import uuid
from django.contrib.contenttypes.models import ContentType
from django.db import models
+from django.utils.translation import gettext_lazy as _
from utilities.fields import RestrictedGenericForeignKey
from ..fields import CachedValueField
@@ -18,6 +19,7 @@ class CachedValue(models.Model):
editable=False
)
timestamp = models.DateTimeField(
+ verbose_name=_('timestamp'),
auto_now_add=True,
editable=False
)
@@ -32,18 +34,25 @@ class CachedValue(models.Model):
fk_field='object_id'
)
field = models.CharField(
+ verbose_name=_('field'),
max_length=200
)
type = models.CharField(
+ verbose_name=_('type'),
max_length=30
)
- value = CachedValueField()
+ value = CachedValueField(
+ verbose_name=_('value'),
+ )
weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
default=1000
)
class Meta:
ordering = ('weight', 'object_type', 'object_id')
+ verbose_name = _('cached value')
+ verbose_name_plural = _('cached values')
def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py
index 6d86e0dfe..b0df9e26e 100644
--- a/netbox/extras/models/staging.py
+++ b/netbox/extras/models/staging.py
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
+from django.utils.translation import gettext_lazy as _
from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel
@@ -22,10 +23,12 @@ class Branch(ChangeLoggedModel):
A collection of related StagedChanges.
"""
name = models.CharField(
+ verbose_name=_('name'),
max_length=100,
unique=True
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True
)
@@ -38,6 +41,8 @@ class Branch(ChangeLoggedModel):
class Meta:
ordering = ('name',)
+ verbose_name = _('branch')
+ verbose_name_plural = _('branches')
def __str__(self):
return f'{self.name} ({self.pk})'
@@ -61,6 +66,7 @@ class StagedChange(ChangeLoggedModel):
related_name='staged_changes'
)
action = models.CharField(
+ verbose_name=_('action'),
max_length=20,
choices=ChangeActionChoices
)
@@ -78,12 +84,15 @@ class StagedChange(ChangeLoggedModel):
fk_field='object_id'
)
data = models.JSONField(
+ verbose_name=_('data'),
blank=True,
null=True
)
class Meta:
ordering = ('pk',)
+ verbose_name = _('staged change')
+ verbose_name_plural = _('staged changes')
def __str__(self):
action = self.get_action_display()
@@ -112,6 +121,7 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete()
+ apply.alters_data = True
def get_action_color(self):
return ChangeActionChoices.colors.get(self.action)
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index 066c0fd78..f4ba5ea64 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -1,9 +1,13 @@
from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
+from django.utils.translation import gettext_lazy as _
from taggit.models import TagBase, GenericTaggedItemBase
+from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices
@@ -24,19 +28,30 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
primary_key=True
)
color = ColorField(
+ verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
+ verbose_name=_('description'),
max_length=200,
blank=True,
)
+ object_types = models.ManyToManyField(
+ to=ContentType,
+ related_name='+',
+ limit_choices_to=FeatureQuery('tags'),
+ blank=True,
+ help_text=_("The object type(s) to which this this tag can be applied.")
+ )
clone_fields = (
- 'color', 'description',
+ 'color', 'description', 'object_types',
)
class Meta:
ordering = ['name']
+ verbose_name = _('tag')
+ verbose_name_plural = _('tags')
def get_absolute_url(self):
return reverse('extras:tag', args=[self.pk])
@@ -61,6 +76,6 @@ class TaggedItem(GenericTaggedItemBase):
)
class Meta:
- index_together = (
- ("content_type", "object_id")
- )
+ indexes = [models.Index(fields=["content_type", "object_id"])]
+ verbose_name = _('tagged item')
+ verbose_name_plural = _('tagged items')
diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py
index 83c7a7bb0..f60462f3d 100644
--- a/netbox/extras/plugins/__init__.py
+++ b/netbox/extras/plugins/__init__.py
@@ -2,7 +2,6 @@ import collections
from importlib import import_module
from django.apps import AppConfig
-from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
from packaging import version
@@ -12,6 +11,7 @@ from netbox.search import register_search
from .navigation import *
from .registration import *
from .templates import *
+from .utils import *
# Initialize plugin registry
registry['plugins'].update({
@@ -146,23 +146,3 @@ class PluginConfig(AppConfig):
for setting, value in cls.default_settings.items():
if setting not in user_config:
user_config[setting] = value
-
-
-#
-# Utilities
-#
-
-def get_plugin_config(plugin_name, parameter, default=None):
- """
- Return the value of the specified plugin configuration parameter.
-
- 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)
- """
- 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/utils.py b/netbox/extras/plugins/utils.py
new file mode 100644
index 000000000..c260f156d
--- /dev/null
+++ b/netbox/extras/plugins/utils.py
@@ -0,0 +1,37 @@
+from django.apps import apps
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+
+__all__ = (
+ 'get_installed_plugins',
+ 'get_plugin_config',
+)
+
+
+def get_installed_plugins():
+ """
+ Return a dictionary mapping the names of installed plugins to their versions.
+ """
+ plugins = {}
+ for plugin_name in settings.PLUGINS:
+ plugin_name = plugin_name.rsplit('.', 1)[-1]
+ plugin_config = apps.get_app_config(plugin_name)
+ plugins[plugin_name] = getattr(plugin_config, 'version', None)
+
+ return dict(sorted(plugins.items()))
+
+
+def get_plugin_config(plugin_name, parameter, default=None):
+ """
+ Return the value of the specified plugin configuration parameter.
+
+ 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)
+ """
+ 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/querysets.py b/netbox/extras/querysets.py
index 7b71fa656..478dedf92 100644
--- a/netbox/extras/querysets.py
+++ b/netbox/extras/querysets.py
@@ -19,8 +19,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
"""
- # `device_role` for Device; `role` for VirtualMachine
- role = getattr(obj, 'device_role', None) or obj.role
+ role = obj.role
# Device type and location assignment is relevant only for Devices
device_type = getattr(obj, 'device_type', None)
@@ -121,7 +120,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
if self.model._meta.model_name == 'device':
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
- base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
+ base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
region_field = 'site__region'
sitegroup_field = 'site__group'
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 8f3af2a09..6af81a9d9 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -214,20 +214,18 @@ class Report(object):
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
+ job.data = self._results
if self.failed:
self.logger.warning("Report failed")
- job.status = JobStatusChoices.STATUS_FAILED
+ job.terminate(status=JobStatusChoices.STATUS_FAILED)
else:
self.logger.info("Report completed successfully")
- job.status = JobStatusChoices.STATUS_COMPLETED
+ job.terminate()
except Exception as e:
stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} {stacktrace}") logger.error(f"Exception raised during report execution: {e}") job.terminate(status=JobStatusChoices.STATUS_ERRORED) - finally: - job.data = self._results - job.terminate() # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 9fa31db31..e93326ddc 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -401,23 +401,23 @@ class BaseScript: def log_debug(self, message): self.logger.log(logging.DEBUG, message) - self.log.append((LogLevelChoices.LOG_DEFAULT, message)) + self.log.append((LogLevelChoices.LOG_DEFAULT, str(message))) def log_success(self, message): self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS - self.log.append((LogLevelChoices.LOG_SUCCESS, message)) + self.log.append((LogLevelChoices.LOG_SUCCESS, str(message))) def log_info(self, message): self.logger.log(logging.INFO, message) - self.log.append((LogLevelChoices.LOG_INFO, message)) + self.log.append((LogLevelChoices.LOG_INFO, str(message))) def log_warning(self, message): self.logger.log(logging.WARNING, message) - self.log.append((LogLevelChoices.LOG_WARNING, message)) + self.log.append((LogLevelChoices.LOG_WARNING, str(message))) def log_failure(self, message): self.logger.log(logging.ERROR, message) - self.log.append((LogLevelChoices.LOG_FAILURE, message)) + self.log.append((LogLevelChoices.LOG_FAILURE, str(message))) # Convenience functions diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4972d9e85..d6550309f 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -10,8 +10,9 @@ from extras.validators import CustomValidator from netbox.config import get_config from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean +from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices -from .models import ConfigRevision, CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook # @@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs): Update the cached NetBox configuration when a new ConfigRevision is created. """ instance.activate() + + +# +# Tags +# + +@receiver(m2m_changed, sender=TaggedItem) +def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): + """ + Validate that any Tags being assigned to the instance are not restricted to non-applicable object types. + """ + if action != 'pre_add': + return + ct = ContentType.objects.get_for_model(instance) + # Retrieve any applied Tags that are restricted to certain object_types + for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): + if ct not in tag.object_types.all(): + raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e4924532..9e14a2d27 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -2,14 +2,18 @@ import json import django_tables2 as tables from django.conf import settings +from django.utils.translation import gettext_lazy as _ from extras.models import * from netbox.tables import NetBoxTable, columns from .template_code import * __all__ = ( + 'BookmarkTable', 'ConfigContextTable', + 'ConfigRevisionTable', 'ConfigTemplateTable', + 'CustomFieldChoiceSetTable', 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', @@ -30,34 +34,118 @@ IMAGEATTACHMENT_IMAGE = ''' {% endif %} ''' +REVISION_BUTTONS = """ +{% if not record.is_active %} + + + +{% endif %} +""" + + +class ConfigRevisionTable(NetBoxTable): + is_active = columns.BooleanColumn( + verbose_name=_('Is Active'), + ) + actions = columns.ActionsColumn( + actions=('delete',), + extra_buttons=REVISION_BUTTONS + ) + + class Meta(NetBoxTable.Meta): + model = ConfigRevision + fields = ( + 'pk', 'id', 'is_active', 'created', 'comment', + ) + default_columns = ('pk', 'id', 'is_active', 'created', 'comment') + class CustomFieldTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn() - required = columns.BooleanColumn() - ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") - description = columns.MarkdownColumn() - is_cloneable = columns.BooleanColumn() + content_types = columns.ContentTypesColumn( + verbose_name=_('Content Types') + ) + required = columns.BooleanColumn( + verbose_name=_('Required') + ) + ui_visibility = columns.ChoiceFieldColumn( + verbose_name=_('UI Visibility') + ) + description = columns.MarkdownColumn( + verbose_name=_('Description') + ) + choice_set = tables.Column( + linkify=True, + verbose_name=_('Choice Set') + ) + choices = columns.ChoicesColumn( + max_items=10, + orderable=False, + verbose_name=_('Choices') + ) + is_cloneable = columns.BooleanColumn( + verbose_name=_('Is Cloneable'), + ) class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created', - 'last_updated', + 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') -class CustomLinkTable(NetBoxTable): +class CustomFieldChoiceSetTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn() - enabled = columns.BooleanColumn() - new_window = columns.BooleanColumn() + base_choices = columns.ChoiceFieldColumn() + extra_choices = tables.TemplateColumn( + template_code="""{% for k, v in value.items %}{{ v }}{% if not forloop.last %}, {% endif %}{% endfor %}""" + ) + choices = columns.ChoicesColumn( + max_items=10, + orderable=False + ) + choice_count = tables.TemplateColumn( + accessor=tables.A('extra_choices'), + template_code='{{ value|length }}', + orderable=False, + verbose_name=_('Count') + ) + order_alphabetically = columns.BooleanColumn( + verbose_name=_('Order Alphabetically'), + ) + + class Meta(NetBoxTable.Meta): + model = CustomFieldChoiceSet + fields = ( + 'pk', 'id', 'name', 'description', 'base_choices', 'extra_choices', 'choice_count', 'choices', + 'order_alphabetically', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'base_choices', 'choice_count', 'description') + + +class CustomLinkTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + content_types = columns.ContentTypesColumn( + verbose_name=_('Content Types'), + ) + enabled = columns.BooleanColumn( + verbose_name=_('Enabled'), + ) + new_window = columns.BooleanColumn( + verbose_name=_('New Window'), + ) class Meta(NetBoxTable.Meta): model = CustomLink @@ -70,19 +158,26 @@ class CustomLinkTable(NetBoxTable): class ExportTemplateTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn() - as_attachment = columns.BooleanColumn() + content_types = columns.ContentTypesColumn( + verbose_name=_('Content Types'), + ) + as_attachment = columns.BooleanColumn( + verbose_name=_('As Attachment'), + ) data_source = tables.Column( + verbose_name=_('Data Source'), linkify=True ) data_file = tables.Column( + verbose_name=_('Data File'), linkify=True ) is_synced = columns.BooleanColumn( orderable=False, - verbose_name='Synced' + verbose_name=_('Synced') ) class Meta(NetBoxTable.Meta): @@ -98,18 +193,23 @@ class ExportTemplateTable(NetBoxTable): class ImageAttachmentTable(NetBoxTable): id = tables.Column( + verbose_name=_('ID'), linkify=False ) - content_type = columns.ContentTypeColumn() + content_type = columns.ContentTypeColumn( + verbose_name=_('Content Type'), + ) parent = tables.Column( + verbose_name=_('Parent'), linkify=True ) image = tables.TemplateColumn( + verbose_name=_('Image'), template_code=IMAGEATTACHMENT_IMAGE, ) size = tables.Column( orderable=False, - verbose_name='Size (bytes)' + verbose_name=_('Size (Bytes)') ) class Meta(NetBoxTable.Meta): @@ -123,11 +223,18 @@ class ImageAttachmentTable(NetBoxTable): class SavedFilterTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn() - enabled = columns.BooleanColumn() - shared = columns.BooleanColumn() + content_types = columns.ContentTypesColumn( + verbose_name=_('Content Types'), + ) + enabled = columns.BooleanColumn( + verbose_name=_('Enabled'), + ) + shared = columns.BooleanColumn( + verbose_name=_('Shared'), + ) def value_parameters(self, value): return json.dumps(value) @@ -143,29 +250,55 @@ class SavedFilterTable(NetBoxTable): ) -class WebhookTable(NetBoxTable): - name = tables.Column( +class BookmarkTable(NetBoxTable): + object_type = columns.ContentTypeColumn( + verbose_name=_('Object Types'), + ) + object = tables.Column( + verbose_name=_('Object'), linkify=True ) - content_types = columns.ContentTypesColumn() - enabled = columns.BooleanColumn() + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = Bookmark + fields = ('pk', 'object', 'object_type', 'created') + default_columns = ('object', 'object_type', 'created') + + +class WebhookTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + content_types = columns.ContentTypesColumn( + verbose_name=_('Content Types'), + ) + enabled = columns.BooleanColumn( + verbose_name=_('Enabled'), + ) type_create = columns.BooleanColumn( - verbose_name='Create' + verbose_name=_('Create') ) type_update = columns.BooleanColumn( - verbose_name='Update' + verbose_name=_('Update') ) type_delete = columns.BooleanColumn( - verbose_name='Delete' + verbose_name=_('Delete') ) type_job_start = columns.BooleanColumn( - verbose_name='Job start' + verbose_name=_('Job Start') ) type_job_end = columns.BooleanColumn( - verbose_name='Job end' + verbose_name=_('Job End') ) ssl_validation = columns.BooleanColumn( - verbose_name='SSL Validation' + verbose_name=_('SSL Validation') + ) + tags = columns.TagColumn( + url_name='extras:webhook_list' ) class Meta(NetBoxTable.Meta): @@ -173,7 +306,7 @@ class WebhookTable(NetBoxTable): fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'http_method', 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', - 'created', 'last_updated', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start', @@ -183,29 +316,38 @@ class WebhookTable(NetBoxTable): class TagTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) - color = columns.ColorColumn() + color = columns.ColorColumn( + verbose_name=_('Color'), + ) + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types'), + ) class Meta(NetBoxTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') + fields = ( + 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', + 'actions', + ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') class TaggedItemTable(NetBoxTable): id = tables.Column( - verbose_name='ID', + verbose_name=_('ID'), linkify=lambda record: record.content_object.get_absolute_url(), accessor='content_object__id' ) content_type = columns.ContentTypeColumn( - verbose_name='Type' + verbose_name=_('Type') ) content_object = tables.Column( linkify=True, orderable=False, - verbose_name='Object' + verbose_name=_('Object') ) actions = columns.ActionsColumn( actions=() @@ -218,20 +360,23 @@ class TaggedItemTable(NetBoxTable): class ConfigContextTable(NetBoxTable): data_source = tables.Column( + verbose_name=_('Data Source'), linkify=True ) data_file = tables.Column( + verbose_name=_('Data File'), linkify=True ) name = tables.Column( + verbose_name=_('Name'), linkify=True ) is_active = columns.BooleanColumn( - verbose_name='Active' + verbose_name=_('Active') ) is_synced = columns.BooleanColumn( orderable=False, - verbose_name='Synced' + verbose_name=_('Synced') ) class Meta(NetBoxTable.Meta): @@ -246,17 +391,20 @@ class ConfigContextTable(NetBoxTable): class ConfigTemplateTable(NetBoxTable): name = tables.Column( + verbose_name=_('Name'), linkify=True ) data_source = tables.Column( + verbose_name=_('Data Source'), linkify=True ) data_file = tables.Column( + verbose_name=_('Data File'), linkify=True ) is_synced = columns.BooleanColumn( orderable=False, - verbose_name='Synced' + verbose_name=_('Synced') ) tags = columns.TagColumn( url_name='extras:configtemplate_list' @@ -275,31 +423,34 @@ class ConfigTemplateTable(NetBoxTable): class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( + verbose_name=_('Time'), linkify=True, format=settings.SHORT_DATETIME_FORMAT ) user_name = tables.Column( - verbose_name='Username' + verbose_name=_('Username') ) full_name = tables.TemplateColumn( accessor=tables.A('user'), template_code=OBJECTCHANGE_FULL_NAME, - verbose_name='Full Name', + verbose_name=_('Full Name'), orderable=False ) - action = columns.ChoiceFieldColumn() + action = columns.ChoiceFieldColumn( + verbose_name=_('Action'), + ) changed_object_type = columns.ContentTypeColumn( - verbose_name='Type' + verbose_name=_('Type') ) object_repr = tables.TemplateColumn( accessor=tables.A('changed_object'), template_code=OBJECTCHANGE_OBJECT, - verbose_name='Object', + verbose_name=_('Object'), orderable=False ) request_id = tables.TemplateColumn( template_code=OBJECTCHANGE_REQUEST_ID, - verbose_name='Request ID' + verbose_name=_('Request ID') ) actions = columns.ActionsColumn( actions=() @@ -315,23 +466,28 @@ class ObjectChangeTable(NetBoxTable): class JournalEntryTable(NetBoxTable): created = tables.DateTimeColumn( + verbose_name=_('Created'), linkify=True, format=settings.SHORT_DATETIME_FORMAT ) assigned_object_type = columns.ContentTypeColumn( - verbose_name='Object type' + verbose_name=_('Object Type') ) assigned_object = tables.Column( linkify=True, orderable=False, - verbose_name='Object' + verbose_name=_('Object') + ) + kind = columns.ChoiceFieldColumn( + verbose_name=_('Kind'), + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), ) - kind = columns.ChoiceFieldColumn() - comments = columns.MarkdownColumn() comments_short = tables.TemplateColumn( accessor=tables.A('comments'), template_code='{{ value|markdown|truncatewords_html:50 }}', - verbose_name='Comments (Short)' + verbose_name=_('Comments (Short)') ) tags = columns.TagColumn( url_name='extras:journalentry_list' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 086c8e246..255457f21 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,6 +1,6 @@ import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware @@ -14,6 +14,9 @@ from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): @@ -95,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): { 'content_types': ['dcim.site'], 'name': 'cf6', - 'type': 'select', - 'choices': ['A', 'B', 'C'] + 'type': 'text', }, ] bulk_update_data = { @@ -131,6 +133,58 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): cf.content_types.add(site_ct) +class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): + model = CustomFieldChoiceSet + brief_fields = ['choices_count', 'display', 'id', 'name', 'url'] + create_data = [ + { + 'name': 'Choice Set 4', + 'extra_choices': [ + ['4A', 'Choice 1'], + ['4B', 'Choice 2'], + ['4C', 'Choice 3'], + ], + }, + { + 'name': 'Choice Set 5', + 'extra_choices': [ + ['5A', 'Choice 1'], + ['5B', 'Choice 2'], + ['5C', 'Choice 3'], + ], + }, + { + 'name': 'Choice Set 6', + 'extra_choices': [ + ['6A', 'Choice 1'], + ['6B', 'Choice 2'], + ['6C', 'Choice 3'], + ], + }, + ] + bulk_update_data = { + 'description': 'New description', + } + update_data = { + 'name': 'Choice Set X', + 'extra_choices': [ + ['X1', 'Choice 1'], + ['X2', 'Choice 2'], + ['X3', 'Choice 3'], + ], + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + class CustomLinkTest(APIViewTestCases.APIViewTestCase): model = CustomLink brief_fields = ['display', 'id', 'name', 'url'] @@ -264,6 +318,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): savedfilter.content_types.set([site_ct]) +class BookmarkTest( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase +): + model = Bookmark + brief_fields = ['display', 'id', 'object_id', 'object_type', 'url'] + + @classmethod + def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + Site(name='Site 5', slug='site-5'), + Site(name='Site 6', slug='site-6'), + ) + Site.objects.bulk_create(sites) + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + + bookmarks = ( + Bookmark(object=sites[0], user=self.user), + Bookmark(object=sites[1], user=self.user), + Bookmark(object=sites[2], user=self.user), + ) + Bookmark.objects.bulk_create(bookmarks) + + self.create_data = [ + { + 'object_type': 'dcim.site', + 'object_id': sites[3].pk, + 'user': self.user.pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[4].pk, + 'user': self.user.pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[5].pk, + 'user': self.user.pk, + }, + ] + + class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['display', 'id', 'name', 'url'] @@ -473,9 +579,9 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase): """ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') - devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') site = Site.objects.create(name='Site-1', slug='site-1') - device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site) + device = Device.objects.create(name='Device 1', device_type=devicetype, role=role, site=site) # Test default config contexts (created at test setup) rendered_context = device.get_config_context() diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index e0be8c3bd..34fd72b2b 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * -from extras.models import CustomField, ObjectChange, Tag +from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag from utilities.testing import APITestCase from utilities.testing.utils import create_tags, post_data from utilities.testing.views import ModelViewTestCase @@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase): @classmethod def setUpTestData(cls): + choice_set = CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=(('foo', 'Foo'), ('bar', 'Bar')) + ) # Create a custom field on the Site model ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() @@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase): # Create a select custom field on the Site model cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site 1', 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_ACTIVE, - 'cf_my_field': 'ABC', - 'cf_my_field_select': 'Bar', + 'cf_cf1': 'ABC', + 'cf_cf2': 'bar', 'tags': [tag.pk for tag in tags], } @@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.prechange_data, None) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): @@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site X', 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, - 'cf_my_field': 'DEF', - 'cf_my_field_select': 'Foo', + 'cf_cf1': 'DEF', + 'cf_cf2': 'foo', 'tags': [tags[2].pk], } @@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.prechange_data['name'], 'Site 1') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) def test_delete_object(self): @@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) @@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase): ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() cf.content_types.set([ct]) # Create a select custom field on the Site model + choice_set = CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=(('foo', 'Foo'), ('bar', 'Bar')) + ) cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site 1', 'slug': 'site-1', 'custom_fields': { - 'my_field': 'ABC', - 'my_field_select': 'Bar', + 'cf1': 'ABC', + 'cf2': 'bar', }, 'tags': [ {'name': 'Tag 1'}, @@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site X', 'slug': 'site-x', 'custom_fields': { - 'my_field': 'DEF', - 'my_field_select': 'Foo', + 'cf1': 'DEF', + 'cf2': 'foo', }, 'tags': [ {'name': 'Tag 3'} @@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3fd0dc83e..019aef235 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from extras.choices import * -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -269,15 +269,25 @@ class CustomFieldTest(TestCase): self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_select_field(self): - CHOICES = ('Option A', 'Option B', 'Option C') - value = CHOICES[1] + CHOICES = ( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ) + value = 'a' + + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -296,15 +306,25 @@ class CustomFieldTest(TestCase): self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_multiselect_field(self): - CHOICES = ['Option A', 'Option B', 'Option C'] - value = [CHOICES[1], CHOICES[2]] + CHOICES = ( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ) + value = ['a', 'b'] + + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='multiselect_field', type=CustomFieldTypeChoices.TYPE_MULTISELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -438,6 +458,12 @@ class CustomFieldAPITest(APITestCase): ) VLAN.objects.bulk_create(vlans) + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz')) + ) + custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), @@ -451,18 +477,14 @@ class CustomFieldAPITest(APITestCase): CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, name='select_field', - default='Foo', - choices=( - 'Foo', 'Bar', 'Baz' - ) + default='foo', + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTISELECT, name='multiselect_field', - default=['Foo'], - choices=( - 'Foo', 'Bar', 'Baz' - ) + default=['foo'], + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, @@ -500,8 +522,8 @@ class CustomFieldAPITest(APITestCase): custom_fields[6].name: '2020-01-02 12:00:00', custom_fields[7].name: 'http://example.com/2', custom_fields[8].name: '{"foo": 1, "bar": 2}', - custom_fields[9].name: 'Bar', - custom_fields[10].name: ['Bar', 'Baz'], + custom_fields[9].name: 'bar', + custom_fields[10].name: ['bar', 'baz'], custom_fields[11].name: vlans[1].pk, custom_fields[12].name: [vlans[2].pk, vlans[3].pk], } @@ -657,8 +679,8 @@ class CustomFieldAPITest(APITestCase): 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0), 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'select_field': 'Bar', - 'multiselect_field': ['Bar', 'Baz'], + 'select_field': 'bar', + 'multiselect_field': ['bar', 'baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, @@ -785,8 +807,8 @@ class CustomFieldAPITest(APITestCase): 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0), 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'select_field': 'Bar', - 'multiselect_field': ['Bar', 'Baz'], + 'select_field': 'bar', + 'multiselect_field': ['bar', 'baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } @@ -1024,6 +1046,16 @@ class CustomFieldImportTest(TestCase): @classmethod def setUpTestData(cls): + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ) + ) + custom_fields = ( CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), @@ -1034,12 +1066,8 @@ class CustomFieldImportTest(TestCase): CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), - CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set), + CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set), ) for cf in custom_fields: cf.save() @@ -1051,8 +1079,8 @@ class CustomFieldImportTest(TestCase): """ data = ( ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', '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', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"'), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -1073,8 +1101,8 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) - self.assertEqual(site1.custom_field_data['select'], 'Choice A') - self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B']) + self.assertEqual(site1.custom_field_data['select'], 'a') + self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b']) # Validate data for site 2 site2 = Site.objects.get(name='Site 2') @@ -1088,8 +1116,8 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) - self.assertEqual(site2.custom_field_data['select'], 'Choice B') - self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C']) + self.assertEqual(site2.custom_field_data['select'], 'b') + self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c']) # No custom field data should be set for site 3 site3 = Site.objects.get(name='Site 3') @@ -1203,6 +1231,11 @@ class CustomFieldModelFilterTest(TestCase): Manufacturer(name='Manufacturer 4', slug='manufacturer-4'), )) + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X')) + ) + # Integer filtering cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) cf.save() @@ -1263,7 +1296,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf9', type=CustomFieldTypeChoices.TYPE_SELECT, - choices=['Foo', 'Bar', 'Baz'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1272,7 +1305,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf10', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=['A', 'B', 'C', 'X'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1305,7 +1338,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', 'cf8': 'http://a.example.com', - 'cf9': 'Foo', + 'cf9': 'A', 'cf10': ['A', 'X'], 'cf11': manufacturers[0].pk, 'cf12': [manufacturers[0].pk, manufacturers[3].pk], @@ -1319,7 +1352,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', 'cf8': 'http://b.example.com', - 'cf9': 'Bar', + 'cf9': 'B', 'cf10': ['B', 'X'], 'cf11': manufacturers[1].pk, 'cf12': [manufacturers[1].pk, manufacturers[3].pk], @@ -1333,7 +1366,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', 'cf8': 'http://c.example.com', - 'cf9': 'Baz', + 'cf9': 'C', 'cf10': ['C', 'X'], 'cf11': manufacturers[2].pk, 'cf12': [manufacturers[2].pk, manufacturers[3].pk], @@ -1399,7 +1432,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index e77afd20e..69111e6a7 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -18,13 +18,20 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +User = get_user_model() + + class CustomFieldTestCase(TestCase, BaseFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) custom_fields = ( CustomField( @@ -51,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN ), + CustomField( + name='Custom Field 4', + type=CustomFieldTypeChoices.TYPE_SELECT, + required=False, + weight=400, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[0] + ), + CustomField( + name='Custom Field 5', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + weight=500, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[1] + ), ) CustomField.objects.bulk_create(custom_fields) - custom_fields[0].content_types.add(content_types[0]) - custom_fields[1].content_types.add(content_types[1]) - custom_fields[2].content_types.add(content_types[2]) + custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site')) + custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack')) + custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) def test_name(self): params = {'name': ['Custom Field 1', 'Custom Field 2']} @@ -64,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): 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]} + params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_required(self): @@ -83,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_choice_set(self): + params = {'choice_set': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): + queryset = CustomFieldChoiceSet.objects.all() + filterset = CustomFieldChoiceSetFilterSet + + @classmethod + def setUpTestData(cls): + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + def test_name(self): + params = {'name': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_choice(self): + params = {'choice': ['A', 'D']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() @@ -362,6 +417,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class BookmarkTestCase(TestCase, BaseFilterSetTests): + queryset = Bookmark.objects.all() + filterset = BookmarkFilterSet + + @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) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + bookmarks = ( + Bookmark( + object=sites[0], + user=users[0], + ), + Bookmark( + object=sites[1], + user=users[1], + ), + Bookmark( + object=sites[2], + user=users[2], + ), + Bookmark( + object=tenants[0], + user=users[0], + ), + Bookmark( + object=tenants[1], + user=users[1], + ), + Bookmark( + object=tenants[2], + user=users[2], + ), + ) + Bookmark.objects.bulk_create(bookmarks) + + def test_object_type(self): + params = {'object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + 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(), 4) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class ExportTemplateTestCase(TestCase, BaseFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet @@ -818,6 +944,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + content_types = { + 'site': ContentType.objects.get_by_natural_key('dcim', 'site'), + 'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'), + } tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), @@ -825,6 +955,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): Tag(name='Tag 3', slug='tag-3', color='0000ff'), ) Tag.objects.bulk_create(tags) + tags[0].object_types.add(content_types['site']) + tags[1].object_types.add(content_types['provider']) # Apply some tags so we can filter by content type site = Site.objects.create(name='Site 1', slug='site-1') @@ -857,6 +989,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'content_type_id': [site_ct, provider_ct]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_object_types(self): + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 1', 'Tag 3'] + ) + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 2', 'Tag 3'] + ) + class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() @@ -965,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase): Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), ) Site.objects.bulk_create(sites) # Simulate *creation* changelog records for two of the sites request_id = uuid.uuid4() + cls.create_request_id = request_id objectchanges = ( ObjectChange( changed_object_type=content_type, @@ -988,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase): # Simulate *update* changelog records for two of the sites request_id = uuid.uuid4() + cls.update_request_id = request_id objectchanges = ( ObjectChange( changed_object_type=content_type, @@ -1004,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase): ) ObjectChange.objects.bulk_create(objectchanges) + # Simulate *create* and *update* changelog records for two of the sites + request_id = uuid.uuid4() + cls.create_update_request_id = request_id + objectchanges = ( + ObjectChange( + changed_object_type=content_type, + changed_object_id=sites[2].pk, + action=ObjectChangeActionChoices.ACTION_CREATE, + request_id=request_id + ), + ObjectChange( + changed_object_type=content_type, + changed_object_id=sites[3].pk, + action=ObjectChangeActionChoices.ACTION_UPDATE, + request_id=request_id + ), + ) + ObjectChange.objects.bulk_create(objectchanges) + def test_created_by_request(self): - request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id - params = {'created_by_request': request_id} + params = {'created_by_request': self.create_request_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - self.assertEqual(self.queryset.count(), 3) + self.assertEqual(self.queryset.count(), 4) def test_updated_by_request(self): - request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id - params = {'updated_by_request': request_id} + params = {'updated_by_request': self.update_request_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - self.assertEqual(self.queryset.count(), 3) + self.assertEqual(self.queryset.count(), 4) + + def test_modified_by_request(self): + params = {'modified_by_request': self.create_update_request_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.queryset.count(), 4) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cc3625c7c..9c22bf83c 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -5,7 +5,7 @@ from dcim.forms import SiteForm from dcim.models import Site from extras.choices import CustomFieldTypeChoices from extras.forms import SavedFilterForm -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet class CustomFieldModelFormTest(TestCase): @@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase): @classmethod def setUpTestData(cls): obj_type = ContentType.objects.get_for_model(Site) - CHOICES = ('A', 'B', 'C') + choice_set = CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C')) + ) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text.content_types.set([obj_type]) @@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase): cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) cf_json.content_types.set([obj_type]) - cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) + cf_select = CustomField.objects.create( + name='select', + type=CustomFieldTypeChoices.TYPE_SELECT, + choice_set=choice_set + ) cf_select.content_types.set([obj_type]) cf_multiselect = CustomField.objects.create( name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES + choice_set=choice_set ) cf_multiselect.content_types.set([obj_type]) diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 0ac63c086..ef9398401 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup +from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -14,6 +16,22 @@ class TagTest(TestCase): self.assertEqual(tag.slug, 'testing-unicode-台灣') + def test_object_type_validation(self): + region = Region.objects.create(name='Region 1', slug='region-1') + sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + + # Create a Tag that can only be applied to Regions + tag = Tag.objects.create(name='Tag 1', slug='tag-1') + tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region')) + + # Apply the Tag to a Region + region.tags.add(tag) + self.assertIn(tag, region.tags.all()) + + # Apply the Tag to a SiteGroup + with self.assertRaises(AbortRequest): + sitegroup.tags.add(tag) + class ConfigContextTest(TestCase): """ @@ -26,7 +44,7 @@ class ConfigContextTest(TestCase): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') - devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') region = Region.objects.create(name='Region') sitegroup = SiteGroup.objects.create(name='Site Group') site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) @@ -40,7 +58,7 @@ class ConfigContextTest(TestCase): Device.objects.create( name='Device 1', device_type=devicetype, - device_role=devicerole, + role=role, site=site, location=location ) @@ -234,7 +252,7 @@ class ConfigContextTest(TestCase): location=location, tenant=tenant, platform=platform, - device_role=DeviceRole.objects.first(), + role=DeviceRole.objects.first(), device_type=DeviceType.objects.first() ) device.tags.add(tag) @@ -364,7 +382,7 @@ class ConfigContextTest(TestCase): site=site, tenant=tenant, platform=platform, - device_role=DeviceRole.objects.first(), + role=DeviceRole.objects.first(), device_type=DeviceType.objects.first() ) device.tags.set(tags) @@ -412,7 +430,7 @@ class ConfigContextTest(TestCase): site=site, tenant=tenant, platform=platform, - device_role=DeviceRole.objects.first(), + role=DeviceRole.objects.first(), device_type=DeviceType.objects.first() ) device.tags.set([tag1, tag2]) diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index cb7629ad2..42dde43fd 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu, get_plugin_config +from extras.plugins import PluginMenu from extras.tests.dummy_plugin import config as dummy_config +from extras.plugins.utils import get_plugin_config from netbox.graphql.schema import Query from netbox.registry import registry diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ef8e87489..01ef9a2a6 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,7 +1,8 @@ +import json import urllib.parse import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -11,6 +12,9 @@ from extras.models import * from utilities.testing import ViewTestCases, TestCase +User = get_user_model() + + class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomField @@ -18,6 +22,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) + CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=( + ('A', 'A'), + ('B', 'B'), + ('C', 'C'), + ) + ) + custom_fields = ( CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT), @@ -41,10 +54,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,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', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write', 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', ) @@ -61,6 +74,52 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = CustomFieldChoiceSet + + @classmethod + def setUpTestData(cls): + + choice_sets = ( + CustomFieldChoiceSet( + name='Choice Set 1', + extra_choices=(('A1', 'Choice 1'), ('A2', 'Choice 2'), ('A3', 'Choice 3')) + ), + CustomFieldChoiceSet( + name='Choice Set 2', + extra_choices=(('B1', 'Choice 1'), ('B2', 'Choice 2'), ('B3', 'Choice 3')) + ), + CustomFieldChoiceSet( + name='Choice Set 3', + extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3')) + ), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + cls.form_data = { + 'name': 'Choice Set X', + 'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3']) + } + + cls.csv_data = ( + 'name,extra_choices', + 'Choice Set 4,"D1,D2,D3"', + 'Choice Set 5,"E1,E2,E3"', + 'Choice Set 6,"F1,F2,F3"', + ) + + cls.csv_update_data = ( + 'id,extra_choices', + f'{choice_sets[0].pk},"A,B,C"', + f'{choice_sets[1].pk},"A,B,C"', + f'{choice_sets[2].pk},"A,B,C"', + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomLink @@ -178,6 +237,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class BookmarkTestCase( + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = Bookmark + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + ) + Site.objects.bulk_create(sites) + + cls.form_data = { + 'object_type': site_ct.pk, + 'object_id': sites[3].pk, + } + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + user = self.user + + bookmarks = ( + Bookmark(object=sites[0], user=user), + Bookmark(object=sites[1], user=user), + Bookmark(object=sites[2], user=user), + ) + Bookmark.objects.bulk_create(bookmarks) + + def _get_url(self, action, instance=None): + if action == 'list': + return reverse('account:bookmarks') + return super()._get_url(action, instance) + + def test_list_objects_anonymous(self): + return + + def test_list_objects_with_constrained_permission(self): + return + + class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ExportTemplate diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 19264dabb..ef7637765 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -31,8 +31,8 @@ class WebhookTest(APITestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) - DUMMY_URL = "http://localhost/" - DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" + DUMMY_URL = 'http://localhost:9000/' + DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING' webhooks = Webhook.objects.bulk_create(( Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), @@ -259,7 +259,7 @@ class WebhookTest(APITestCase): name='Conditional Webhook', type_create=True, type_update=True, - payload_url='http://localhost/', + payload_url='http://localhost:9000/', conditions={ 'and': [ { diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index c4fc3d938..fd95186e4 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, path, re_path +from django.urls import include, path from extras import views from utilities.urls import get_model_urls @@ -15,6 +15,14 @@ urlpatterns = [ path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields/
- There was a problem with your request. Please contact an administrator. + {% trans "There was a problem with your request. Please contact an administrator" %}.
{% endblock %}- The complete exception is provided below: + {% trans "The complete exception is provided below" %}:
{{ exception }}+{% trans "Python version" %}: {{ python_version }} +{% trans "NetBox version" %}: {{ netbox_version }} +{% trans "Plugins" %}: {% for plugin, version in plugins.items %} + {{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %} +
{{ error }} -Python version: {{ python_version }} -NetBox version: {{ netbox_version }}
- If further assistance is required, please post to the NetBox discussion forum on GitHub. + {% trans "If further assistance is required, please post to the" %} {% trans "NetBox discussion forum" %} {% trans "on GitHub" %}.