Compare commits

...

67 Commits

Author SHA1 Message Date
Jeremy Stretch
5709bc3b2b Release v3.6-beta2 2023-08-16 11:28:31 -04:00
Jeremy Stretch
af06510921 Closes #13412: Enable pagination of custom field choice set choices 2023-08-16 11:08:36 -04:00
Jeremy Stretch
b4acbb5e16 Closes #13439: Update API token documentation 2023-08-16 10:28:33 -04:00
Jeremy Stretch
b96e437e2b #8248: Add bookmarks widget to default dashboard 2023-08-16 10:10:31 -04:00
Jeremy Stretch
0457520f51 Changelog for #12461 2023-08-15 11:25:56 -04:00
Jeremy Stretch
44f8a777df Merge branch 'develop' into feature 2023-08-15 11:04:03 -04:00
Jeremy Stretch
1c9a8ec6bd PRVB 2023-08-15 10:00:24 -04:00
Jeremy Stretch
e61795d5c6 Release v3.5.8 2023-08-15 09:18:15 -04:00
Joel D. Tague
892c10b1f0 feat: add 200Gbps & 400Gbps interface speed options 2023-08-15 09:11:40 -04:00
Abhimanyu Saharan
752e26c7de Adds config template to vm model (#13450)
* adds config template to vm model #12461

* Add translation tags; collapse config data

* i18n cleanup

* Establish parity with DeviceRenderConfigView

* Move config_template field to RenderConfigMixin

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-14 15:43:28 -04:00
Abhimanyu Saharan
ea107b6b86 adds object view to allow changelog page to be opened #13463 2023-08-14 09:47:58 -04:00
Jeremy Stretch
b9b9c065cc Changelog for #10030, #11578, #12639 2023-08-14 08:55:47 -04:00
Jeremy Stretch
b583770765 Fixes #13451: Disable table ordering for custom link columns 2023-08-14 08:51:16 -04:00
Abhimanyu Saharan
37d6f6abca Merge pull request #13461 from netbox-community/fix/13460-spelling
Fixed spelling for Attributes
2023-08-14 01:18:37 -07:00
Abhimanyu Saharan
be3f48c677 Fixed spelling for Attributes #13460 2023-08-14 13:29:11 +05:30
kkthxbye
5de9d3f15f Fixes #12639 - Make sure name expansions throws a validation error on decrementing ranges (#13326)
* Fixes #12639 - Make sure name expansions throws a validation error on decrementing ranges

* Fix pep8

* Also fail on equal start & end values

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-11 11:53:16 -04:00
Arthur Hanson
8593715149 13319 add documentation for internationalization (#13330)
* 13319 add documentation for internationalization

* 13319 add verbose name to model

* 13319 fix typo

* Flesh out developer doc for i18n

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-11 11:27:48 -04:00
Daniel W. Anner
40afe6cf36 Feature - Schema Generation (#13353)
* Schema generation is working

* Added option to either dump to a file or the console

* Moving schema file and utilizing settings definition for file paths

* Cleaning up the imports and fixing a few pythonic issues

* Tweak command flags

* Clean up choices mapping

* Misc cleanup

* Rename & move template file

* Move management command from extras to dcim

* Update release checklist

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-11 11:00:26 -04:00
Arthur Hanson
9fd07b594c 11578 mark swagger available- apis to accept lists in post (#13445)
* 11578 change swagger for available-ips to accept lists

* 11578 change swagger for available-xxx to accept lists
2023-08-11 09:49:03 -04:00
Jeremy Stretch
dc7411e4c5 Fixes #13446: Don't disable bulk edit/delete buttons after deselecting "select all" checkbox 2023-08-11 08:56:58 -04:00
Jeremy Stretch
315c4bb1ac #13434: Fix tests 2023-08-10 14:32:48 -04:00
Jeremy Stretch
1ff1b4dc89 Changelog for #13433, #13434, #13437 2023-08-10 14:12:42 -04:00
Jeremy Stretch
a332adf962 Fixes #13434: Randomly generate initial keys prior to the creation of new tokens 2023-08-10 14:11:16 -04:00
Jeremy Stretch
856cc0f885 Fixes #13437: Display bookmark button only for relevant objects 2023-08-10 13:55:03 -04:00
Jeremy Stretch
89d8f7aa70 Add missing load tag for i18n 2023-08-10 10:32:56 -04:00
Jeremy Stretch
4d2ef0a8b5 Fixes #13433: User field on API token form should be required 2023-08-10 10:04:31 -04:00
Jeremy Stretch
23b3f72dee Apply missed string translations 2023-08-10 09:38:12 -04:00
Jeremy Stretch
ff59845821 Changelog for #12814, #13037, #13376, #13410 2023-08-09 15:38:03 -04:00
Jeremy Stretch
914588f55d Merge branch 'develop' into feature 2023-08-09 15:31:21 -04:00
Jeremy Stretch
72e1e8fab1 Changelog for #11675, #11922, #12665, #13368, #13414 2023-08-09 15:02:49 -04:00
Abhimanyu Saharan
8b01c30c51 Exposes all models in device context data (#13389)
* exposes all models in device context data #12814

* added app namespaces to the context data

* revert object to device in context data

* moved context to render method of ConfigTemplate

* removed print

* Include only registered models; permit passed context data to overwrite apps

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-09 14:57:59 -04:00
Arthur
dcdb4d27ec 12665 add semicolon to link sanitation safe string 2023-08-09 14:49:34 -04:00
kkthxbye-code
9b1406a1a7 Don't hide HIDDEN_IFUNSET custom fields from bulk import fields 2023-08-09 14:47:20 -04:00
Abhimanyu Saharan
545769ad88 Adds generic object children template (#13388)
* adds generic tab view template #12110

* Rename view_tab.html and move to generic/

* Fix console ports template

* Move bulk operations view resolution to template

* Avoid setting default template_name on ObjectChildrenView

* Move base_template and table_config context vars to base context

* removed bulk_delete_control from templates

* refactored bulk_controls view

* fixed table_config

* renamed object_tab.html to objectchildren_list.html

* removed unused import

* Refactor template blocks for bulk operation buttons

* Rename object children generic template

* Move disconnect bulk action into a separate template for device components

* Fix cluster devices & VM interfaces views

* minor button label change

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-09 14:16:03 -04:00
Jeremy Stretch
16bcb1dbb0 #13426: Employ proper feature keys for image attachment & contact filter forms 2023-08-09 10:41:40 -04:00
Jeremy Stretch
5dce5563ab #11541: Fix object_types queryset on TagSerializer 2023-08-09 10:32:08 -04:00
Jeremy Stretch
4e8a3e0a6f Closes #13426: Register all model features in the registry 2023-08-09 10:27:10 -04:00
Jeremy Stretch
646d52d498 Misc docs cleanup for v3.6 2023-08-09 10:12:40 -04:00
Jeremy Stretch
cd5012bd59 Closes #13424: Move CloningMixin into NetBoxFeatureSet 2023-08-09 10:12:13 -04:00
Jeremy Stretch
4bb0388118 Fixes #13362: Limit displayed choice set list to 50 choices 2023-08-08 09:47:34 -04:00
Jeremy Stretch
f255fe507d Fixes #13410: Fix rendering of custom choice fields with large numner of choices 2023-08-08 09:32:56 -04:00
Jeremy Stretch
f5a1f83f9f Closes #13368: Report installed plugins during server error (#13387)
* Introduce get_installed_plugins() utility

* Extend 500 error template to list installed plugins

* Move get_plugin_config() to extras.plugins.utils
2023-08-07 15:29:20 -04:00
Jeremy Stretch
36072f17a9 Define LOCALE_PATHS 2023-08-07 14:34:56 -04:00
Jeremy Stretch
f9648d8544 Closes #13400: Add 'name' property to BaseTable class 2023-08-07 10:48:41 -04:00
Jeremy Stretch
2236b86c35 Closes #11922: Populate assigned VDCs when adding a child interface 2023-08-04 15:25:59 -04:00
Jeremy Stretch
0dd319d0c8 Closes #11675: Add support for specifying import/export route targets during VRF bulk import 2023-08-04 15:25:06 -04:00
Abhimanyu Saharan
53615944c5 Adds standardized list API for scripts and reports (#13382)
* adds standardized list API for scripts and reports #13037

* adds standardized list API for scripts and reports #13037

* adds standardized list API for scripts and reports #13037

* adds module name to the display #13037
2023-08-04 15:23:15 -04:00
Jeremy Stretch
88562d7dcf Changelog for #12750, #12889, #13033, #13151, #13343, #13369 2023-08-04 13:36:33 -04:00
Abhimanyu Saharan
01bb09db67 adds delete for SyncedDataMixin when related AutoSyncRecord is available #12750 2023-08-04 13:25:56 -04:00
Jeremy Stretch
f1c182bb65 Fixes #13376: Restrict add/remove tag fields by model on bulk edit forms 2023-08-04 13:09:07 -04:00
Henrik Strand
43ce453938 Adding interface TYPE_400GE_CFP2/400gbase-x-cfp2 (#13338)
* Added 400G CFP2 to InterfaceTypeChoices

* Added new type to choises
2023-08-04 11:32:52 -04:00
Jeremy Stretch
2afce6c94b Introduce ContactsMixin 2023-08-04 10:15:50 -04:00
Jeremy Stretch
14e23c3d00 Introduce ImageAttachmentsMixin 2023-08-04 10:15:50 -04:00
Jeremy Stretch
7f22c6bf12 Include notes re: demo data and netbox-docker 2023-08-04 10:12:15 -04:00
Jeremy Stretch
93a862cded Add stadium analogy and behavior anti-patterns 2023-08-04 08:55:43 -04:00
Jeremy Stretch
9cc295827b Fixes #13369: Fix job termination status for failed reports 2023-08-04 08:12:52 -04:00
Jeremy Stretch
14988fc91c Remove redundant overrides of EXEMPT_VIEW_PERMISSIONS 2023-08-03 11:07:30 -04:00
Jeremy Stretch
31f41855f4 Closes #13367: Delete unused device component deletion templates 2023-08-03 10:49:40 -04:00
Jeremy Stretch
caedc8dbe3 Closes #13352: Translation support for model verbose names (#13354)
* Update verbose_name & verbose_name_plural Meta attributes on all models

* Alter makemigrations to ignore verbose_name & verbose_name_plural changes
2023-08-03 10:41:10 -04:00
Jeremy Stretch
24ffaf09d4 Fixes #13363: Fix API endpoint for custom field choice selector in forms 2023-08-03 08:53:46 -04:00
Jeremy Stretch
d9f3637e25 Fixes #13361: Extra choices field on custom field choice set form should not be required 2023-08-03 07:49:54 -04:00
Matej Vadnjal
a807cca29e Fixes #13033: add formatted speed column to Interfaces (#13275)
* Fixes #13033: add formatted speed column to Interfaces

* use TemplateColumn instead of own class
2023-08-02 16:08:14 -04:00
Abhimanyu Saharan
57860f26b7 Adds assigned bool for IP address API (#13301)
* adds assigned bool for ip address API #13151

* Add filterset test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-02 15:45:09 -04:00
Abhimanyu Saharan
ab916a1819 fixes dummy payload URL for webhook test 2023-08-02 15:23:05 -04:00
Abhimanyu Saharan
a68831d3a1 fixes provider_network_id for related circuits #13343 2023-08-02 15:17:14 -04:00
Jeremy Stretch
04a2543e68 Fixes #13351: Fix missing text due to incorrectly applied translation tags 2023-08-02 14:53:32 -04:00
Jeremy Stretch
a4c9cbc6dd Remove hard-coded test runner 2023-08-02 08:55:38 -04:00
170 changed files with 2325 additions and 1472 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.5.7
placeholder: v3.5.8
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.5.7
placeholder: v3.5.8
validations:
required: true
- type: dropdown

View File

@@ -14,12 +14,25 @@
</div>
<h3></h3>
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

View File

@@ -0,0 +1,561 @@
{
"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",
"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"
]
}
}
}
}
}

View File

@@ -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.
---

View File

@@ -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 #}
<h5 class="card-header">{% trans "Circuit List" %}</h5>
{# A longer string with a context variable #}
{% blocktrans with count=object.circuits.count %}
There are {count} circuits. Would you like to continue?
{% endblocktrans %}
```
!!! warning
The `{% blocktrans %}` tag supports only **limited variable replacement**, comparable to the `format()` method on Python strings. It does not permit access to object attributes or the use of other template tags or filters inside it. Ensure that any necessary context is passed as simple variables.
!!! info
The `{% trans %}` and `{% blocktrans %}` support the inclusion of contextual hints for translators using the `context` argument:
```nohighlight
{% trans "May" context "month name" %}
```

View File

@@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
### Rebuild Demo Data (After Release)
After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
---
## Patch Releases
### Notify netbox-docker Project of Any Relevant Changes
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
* Significant changes to `upgrade.sh`
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
* Any changes to the reference installation
### Update Requirements
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
@@ -58,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
### Rebuild the Device Type Definition Schema
Run the following command to update the device type definition validation schema:
```nohighlight
./manage.py buildschema --write
```
This will automatically update the schema file at `contrib/generated_schema.json`.
### Update Version and Changelog
* Update the `VERSION` constant in `settings.py` to the new release version.

View File

@@ -570,27 +570,26 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
!!! note
All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts.
By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
!!! warning "Restricting Token Retrieval"
!!! info "Restricting Token Retrieval"
The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
### Restricting Write Operations
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
#### Client IP Restriction
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
#### Creating Tokens for Other Users
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
![Adding the grant action to a permission](../media/admin_ui_grant_permission.png)
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.
!!! warning "Exercise Caution"
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
@@ -627,7 +626,7 @@ When a token is used to authenticate a request, its `last_updated` time updated
### Initial Token Provisioning
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
Ideally, each user should provision his or her own API token(s) via the web UI. However, you may encounter a scenario where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. (Note that the user must have permission to create API tokens regardless of the interface used.)
To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -26,7 +26,9 @@ Every model includes by default a numeric primary key. This value is generated a
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
* Bookmarks
* Change logging
* Cloning
* Custom fields
* Custom links
* Custom validation
@@ -105,6 +107,8 @@ For more information about database migrations, see the [Django documentation](h
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
::: netbox.models.features.BookmarksMixin
::: netbox.models.features.ChangeLoggingMixin
::: netbox.models.features.CloningMixin

View File

@@ -1,6 +1,33 @@
# NetBox v3.5
## v3.5.8 (FUTURE)
## v3.5.9 (FUTURE)
---
## v3.5.8 (2023-08-15)
### Enhancements
* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release
* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import
* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI
* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page
* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form
### Bug Fixes
* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints
* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects
* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links
* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms
* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox
* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns
---

View File

@@ -1,5 +1,20 @@
# NetBox v3.6
## v3.6-beta2 (2023-08-16)
### Bug Fixes
* [#13351](https://github.com/netbox-community/netbox/issues/13351) - Fix missing text due to incorrectly applied translation tags
* [#13361](https://github.com/netbox-community/netbox/issues/13361) - Extra choices field on custom field choice set form should not be required
* [#13363](https://github.com/netbox-community/netbox/issues/13363) - Fix API endpoint for custom field choice selector in forms
* [#13376](https://github.com/netbox-community/netbox/issues/13376) - Restrict add/remove tag fields by model on bulk edit forms
* [#13410](https://github.com/netbox-community/netbox/issues/13410) - Fix rendering of custom choice fields with large number of choices
* [#13433](https://github.com/netbox-community/netbox/issues/13433) - User field on API token form should be required
* [#13434](https://github.com/netbox-community/netbox/issues/13434) - Randomly generate initial keys prior to the creation of new tokens
* [#13437](https://github.com/netbox-community/netbox/issues/13437) - Display bookmark button only for relevant objects
---
## v3.6-beta1 (2023-08-02)
### Breaking Changes
@@ -8,6 +23,8 @@
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
* Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
### New Features
@@ -60,7 +77,10 @@ Tags may now be restricted to use with designated object types. Tags that have n
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
* [#12461](https://github.com/netbox-community/netbox/issues/12461) - Add config template rendering for virtual machines
* [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context
* [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments
* [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
@@ -112,6 +132,10 @@ Tags may now be restricted to use with designated object types. Tags that have n
* extras.CustomField
* Removed the `choices` array field
* Added the `choice_set` foreign key field (to ChoiceSet)
* extras.Report
* Reports are now returned within a `results` list
* extras.Script
* Scripts are now returned within a `results` list
* extras.Tag
* Added the `object_types` field for optional restriction to specific object types
* extras.Webhook

View File

@@ -211,6 +211,7 @@ nav:
- ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md'
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
- CustomLink: 'models/extras/customlink.md'
- ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md'
@@ -270,6 +271,7 @@ nav:
- Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md'
- Web UI: 'development/web-ui.md'
- Internationalization: 'development/internationalization.md'
- Release Checklist: 'development/release-checklist.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:

View File

@@ -1,4 +1,3 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -6,9 +5,8 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from dcim.models import CabledObjectModel
from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
)
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
__all__ = (
'Circuit',
@@ -25,8 +23,13 @@ class CircuitType(OrganizationalModel):
def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk])
class Meta:
ordering = ('name',)
verbose_name = _('circuit type')
verbose_name_plural = _('circuit types')
class Circuit(PrimaryModel):
class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
@@ -84,14 +87,6 @@ class Circuit(PrimaryModel):
help_text=_("Committed rate")
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
# Cache associated CircuitTerminations
termination_a = models.ForeignKey(
to='circuits.CircuitTermination',
@@ -131,6 +126,8 @@ class Circuit(PrimaryModel):
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
verbose_name = _('circuit')
verbose_name_plural = _('circuits')
def __str__(self):
return self.cid
@@ -217,6 +214,8 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side'
),
)
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')
def __str__(self):
return f'{self.circuit}: Termination {self.term_side}'

View File

@@ -1,10 +1,10 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = (
'ProviderNetwork',
@@ -13,7 +13,7 @@ __all__ = (
)
class Provider(PrimaryModel):
class Provider(ContactsMixin, PrimaryModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider.
@@ -35,15 +35,12 @@ class Provider(PrimaryModel):
blank=True
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
clone_fields = ()
class Meta:
ordering = ['name']
verbose_name = _('provider')
verbose_name_plural = _('providers')
def __str__(self):
return self.name
@@ -52,7 +49,7 @@ class Provider(PrimaryModel):
return reverse('circuits:provider', args=[self.pk])
class ProviderAccount(PrimaryModel):
class ProviderAccount(ContactsMixin, PrimaryModel):
"""
This is a discrete account within a provider. Each Circuit belongs to a Provider Account.
"""
@@ -71,11 +68,6 @@ class ProviderAccount(PrimaryModel):
blank=True
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
clone_fields = ('provider', )
class Meta:
@@ -91,6 +83,8 @@ class ProviderAccount(PrimaryModel):
condition=~Q(name="")
),
)
verbose_name = _('provider account')
verbose_name_plural = _('provider accounts')
def __str__(self):
if self.name:
@@ -129,6 +123,8 @@ class ProviderNetwork(PrimaryModel):
name='%(app_label)s_%(class)s_unique_provider_name'
),
)
verbose_name = _('provider network')
verbose_name_plural = _('provider networks')
def __str__(self):
return self.name

View File

@@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
related_models = (
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'providernetwork_id',
'provider_network_id',
),
)

View File

@@ -3,9 +3,15 @@ from django.conf import settings
from django.core.management.base import CommandError
from django.core.management.commands.makemigrations import Command as _Command
from django.db import models
from django.db.migrations.operations import AlterModelOptions
from utilities.migration import custom_deconstruct
# Monkey patch AlterModelOptions to ignore verbose name attributes
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
# Set our custom deconstructor for fields
models.Field.deconstruct = custom_deconstruct

View File

@@ -83,6 +83,8 @@ class DataSource(JobsMixin, PrimaryModel):
class Meta:
ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
def __str__(self):
return f'{self.name}'
@@ -300,6 +302,8 @@ class DataFile(models.Model):
indexes = [
models.Index(fields=('source', 'path'), name='core_datafile_source_path'),
]
verbose_name = _('data file')
verbose_name_plural = _('data files')
def __str__(self):
return self.path
@@ -383,3 +387,5 @@ class AutoSyncRecord(models.Model):
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('auto sync record')
verbose_name_plural = _('auto sync records')

View File

@@ -56,6 +56,8 @@ class ManagedFile(SyncedDataMixin, models.Model):
indexes = [
models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
]
verbose_name = _('managed file')
verbose_name_plural = _('managed files')
def __str__(self):
return self.name

View File

@@ -101,6 +101,8 @@ class Job(models.Model):
class Meta:
ordering = ['-created']
verbose_name = _('job')
verbose_name_plural = _('jobs')
def __str__(self):
return str(self.job_id)

View File

@@ -836,6 +836,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
@@ -978,6 +979,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP, 'CFP (100GE)'),
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
@@ -1141,6 +1143,8 @@ class InterfaceSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
]

View File

@@ -1098,6 +1098,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
queryset=VirtualDeviceContext.objects.all(),
required=False,
label=_('Virtual device contexts'),
initial_params={
'interfaces': '$parent',
},
query_params={
'device_id': '$device',
}

View File

@@ -55,7 +55,10 @@ class ComponentCreateForm(forms.Form):
super().clean()
# Validate that all replication fields generate an equal number of values
pattern_count = len(self.cleaned_data[self.replication_fields[0]])
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
return
pattern_count = len(patterns)
for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:

View File

@@ -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)

View File

@@ -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',

View File

@@ -91,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)
@@ -292,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}'
@@ -427,6 +431,10 @@ class CablePath(models.Model):
)
_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"

View File

@@ -183,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')),
@@ -213,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')),
@@ -258,6 +266,10 @@ class PowerPortTemplate(ModularComponentTemplateModel):
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')),
@@ -316,6 +328,10 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
component_model = PowerOutlet
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('power outlet template')
verbose_name_plural = _('power outlet templates')
def clean(self):
super().clean()
@@ -410,6 +426,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
component_model = Interface
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('interface template')
verbose_name_plural = _('interface templates')
def clean(self):
super().clean()
@@ -503,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()
@@ -579,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')),
@@ -614,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,
@@ -638,6 +668,10 @@ 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,
@@ -720,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

View File

@@ -298,6 +298,10 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
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})
@@ -323,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})
@@ -359,6 +367,10 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
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})
@@ -473,6 +485,10 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
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})
@@ -718,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})
@@ -977,6 +995,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
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})
@@ -1032,6 +1052,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
)
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})
@@ -1066,6 +1090,10 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
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})
@@ -1084,6 +1112,10 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
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})
@@ -1125,6 +1157,11 @@ class InventoryItemRole(OrganizationalModel):
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])
@@ -1209,6 +1246,8 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
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})

View File

@@ -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
@@ -20,11 +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, CounterCacheField, NaturalOrderingField
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import WeightMixin
from .mixins import RenderConfigMixin, WeightMixin
__all__ = (
@@ -44,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).
@@ -175,10 +175,6 @@ class DeviceType(PrimaryModel, WeightMixin):
to_field='device_type'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = (
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit',
@@ -199,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
@@ -359,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
@@ -382,11 +380,6 @@ class ModuleType(PrimaryModel, WeightMixin):
help_text=_('Discrete part number (optional)')
)
# Generic relations
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
prerequisite_models = (
'dcim.Manufacturer',
@@ -400,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
@@ -477,6 +472,11 @@ 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])
@@ -502,6 +502,11 @@ class Platform(OrganizationalModel):
null=True
)
class Meta:
ordering = ('name',)
verbose_name = _('platform')
verbose_name_plural = _('platforms')
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
@@ -520,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
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.
@@ -681,13 +693,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
validators=[MaxValueValidator(255)],
help_text=_('Virtual chassis master election priority')
)
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
latitude = models.DecimalField(
verbose_name=_('latitude'),
max_digits=8,
@@ -747,14 +752,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
to_field='device'
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = (
@@ -789,6 +786,8 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
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:
@@ -1071,17 +1070,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
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.role.config_template:
return self.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.
@@ -1182,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})'
@@ -1314,7 +1304,8 @@ class VirtualChassis(PrimaryModel):
class Meta:
ordering = ['name']
verbose_name_plural = 'virtual chassis'
verbose_name = _('virtual chassis')
verbose_name_plural = _('virtual chassis')
def __str__(self):
return self.name
@@ -1415,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

View File

@@ -4,6 +4,11 @@ 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(
@@ -44,3 +49,27 @@ 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"))
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

View File

@@ -1,4 +1,3 @@
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
@@ -8,6 +7,7 @@ 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.
"""
@@ -40,14 +40,6 @@ class PowerPanel(PrimaryModel):
max_length=100
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
prerequisite_models = (
'dcim.Site',
)
@@ -60,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
@@ -166,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

View File

@@ -15,6 +15,7 @@ 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
@@ -43,11 +44,16 @@ class RackRole(OrganizationalModel):
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.
@@ -188,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',
@@ -216,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:
@@ -538,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)

View File

@@ -8,6 +8,7 @@ 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 = (
@@ -62,6 +59,8 @@ class Region(NestedGroupModel):
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 = (
@@ -117,6 +112,8 @@ class SiteGroup(NestedGroupModel):
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,7 +129,7 @@ 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).
@@ -230,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',
@@ -244,6 +235,8 @@ class Site(PrimaryModel):
class Meta:
ordering = ('_name',)
verbose_name = _('site')
verbose_name_plural = _('sites')
def __str__(self):
return self.name
@@ -259,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.
@@ -290,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 = (
@@ -326,6 +313,8 @@ class Location(NestedGroupModel):
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])

View File

@@ -591,7 +591,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
}
)
mgmt_only = columns.BooleanColumn(
verbose_name=_('Management Only'),
verbose_name=_('Management Only')
)
speed_formatted = columns.TemplateColumn(
template_code='{% load helpers %}{{ value|humanize_speed }}',
accessor=Accessor('speed'),
verbose_name=_('Speed')
)
wireless_link = tables.Column(
verbose_name=_('Wireless link'),
@@ -618,7 +623,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',

View File

@@ -1,4 +1,5 @@
import traceback
from collections import defaultdict
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
@@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
action_perms = defaultdict(set, **{
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
})
queryset = Device.objects.all()
def get_children(self, request, parent):
@@ -1997,6 +2007,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
template_name = 'dcim/device/modulebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
tab = ViewTab(
label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count,
@@ -2012,6 +2023,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
template_name = 'dcim/device/devicebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
tab = ViewTab(
label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count,
@@ -2023,6 +2035,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
@register_model_view(Device, 'inventory')
class DeviceInventoryView(DeviceComponentsView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
child_model = InventoryItem
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet

View File

@@ -239,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
object_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
many=True,
required=False
)
@@ -481,6 +481,11 @@ class ReportSerializer(serializers.Serializer):
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(serializers.CharField())
def get_display(self, obj):
return f'{obj.name} ({obj.module})'
class ReportDetailSerializer(ReportSerializer):
@@ -518,6 +523,7 @@ class ScriptSerializer(serializers.Serializer):
description = serializers.CharField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_vars(self, instance):
@@ -525,6 +531,10 @@ class ScriptSerializer(serializers.Serializer):
k: v.__class__.__name__ for k, v in instance._get_vars().items()
}
@extend_schema_field(serializers.CharField())
def get_display(self, obj):
return f'{obj.name} ({obj.module})'
class ScriptDetailSerializer(ScriptSerializer):
result = JobSerializer()

View File

@@ -80,7 +80,7 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
# Paginate data
if page := self.paginate_queryset(choices):
data = [
{'value': c[0], 'label': c[1]} for c in page
{'id': c[0], 'display': c[1]} for c in page
]
return self.get_paginated_response(data)
@@ -243,7 +243,7 @@ class ReportViewSet(ViewSet):
'request': request,
})
return Response(serializer.data)
return Response({'count': len(report_list), 'results': serializer.data})
def retrieve(self, request, pk):
"""
@@ -343,7 +343,7 @@ class ScriptViewSet(ViewSet):
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
return Response(serializer.data)
return Response({'count': len(script_list), 'results': serializer.data})
def retrieve(self, request, pk):
module, script = self._get_script(pk)

View File

@@ -19,6 +19,13 @@ WEBHOOK_EVENT_TYPES = {
# Dashboard
DEFAULT_DASHBOARD = [
{
'widget': 'extras.BookmarksWidget',
'width': 4,
'height': 5,
'title': 'Bookmarks',
'color': 'orange',
},
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
@@ -32,22 +39,6 @@ DEFAULT_DASHBOARD = [
]
}
},
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'IPAM',
'config': {
'models': [
'ipam.vrf',
'ipam.aggregate',
'ipam.prefix',
'ipam.iprange',
'ipam.ipaddress',
'ipam.vlan',
]
}
},
{
'widget': 'extras.NoteWidget',
'width': 4,
@@ -65,13 +56,16 @@ DEFAULT_DASHBOARD = [
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 2,
'title': 'Circuits',
'height': 3,
'title': 'IPAM',
'config': {
'models': [
'circuits.provider',
'circuits.circuit',
'circuits.providernetwork',
'ipam.vrf',
'ipam.aggregate',
'ipam.prefix',
'ipam.iprange',
'ipam.ipaddress',
'ipam.vlan',
]
}
},
@@ -86,6 +80,20 @@ DEFAULT_DASHBOARD = [
'cache_timeout': 14400,
}
},
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'Circuits',
'config': {
'models': [
'circuits.provider',
'circuits.circuit',
'circuits.providernetwork',
'circuits.provideraccount',
]
}
},
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,

View File

@@ -180,7 +180,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
)
content_type_id = ContentTypeChoiceField(
label=_('Content type'),
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
required=False
)
name = forms.CharField(

View File

@@ -89,6 +89,11 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ChoicesWidget(),
required=False,
help_text=_(
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
'comma (for example, "choice1,First Choice").'
)
)
class Meta:

View File

@@ -93,6 +93,8 @@ class ObjectChange(models.Model):
class Meta:
ordering = ['-time']
verbose_name = _('object change')
verbose_name_plural = _('object changes')
def __str__(self):
return '{} {} {} by {}'.format(

View File

@@ -1,3 +1,4 @@
from django.apps import apps
from django.conf import settings
from django.core.validators import ValidationError
from django.db import models
@@ -8,6 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment
from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
from netbox.registry import registry
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.jinja2 import ConfigTemplateLoader
@@ -125,6 +127,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
class Meta:
ordering = ['weight', 'name']
verbose_name = _('config context')
verbose_name_plural = _('config contexts')
def __str__(self):
return self.name
@@ -233,6 +237,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
class Meta:
ordering = ('name',)
verbose_name = _('config template')
verbose_name_plural = _('config templates')
def __str__(self):
return self.name
@@ -251,7 +257,19 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
"""
Render the contents of the template.
"""
context = context or {}
_context = dict()
# Populate the default template context with NetBox model classes, namespaced by app
# TODO: Devise a canonical mechanism for identifying the models to include (see #13427)
for app, model_names in registry['model_features']['custom_fields'].items():
_context.setdefault(app, {})
for model_name in model_names:
model = apps.get_registered_model(app, model_name)
_context[app][model.__name__] = model
# Add the provided context data, if any
if context is not None:
_context.update(context)
# Initialize the Jinja2 environment and instantiate the Template
environment = self._get_environment()
@@ -259,7 +277,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**context)
output = template.render(**_context)
# Replace CRLF-style line terminators
return output.replace('\r\n', '\n')

View File

@@ -202,6 +202,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
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()
@@ -439,18 +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 DynamicChoiceField
widget_class = APISelect
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 DynamicMultipleChoiceField
widget_class = APISelectMultiple
field = field_class(
choices=choices,
required=required,
initial=initial,
widget=widget_class(api_url=f'/api/extras/custom-field-choices/{self.choice_set.pk}/choices/')
)
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:
@@ -710,6 +719,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
class Meta:
ordering = ('name',)
verbose_name = _('custom field choice set')
verbose_name_plural = _('custom field choice sets')
def __str__(self):
return self.name

View File

@@ -25,7 +25,8 @@ class Dashboard(models.Model):
)
class Meta:
pass
verbose_name = _('dashboard')
verbose_name_plural = _('dashboards')
def get_widget(self, id):
"""

View File

@@ -165,6 +165,8 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
name='%(app_label)s_%(class)s_unique_payload_url_types'
),
)
verbose_name = _('webhook')
verbose_name_plural = _('webhooks')
def __str__(self):
return self.name
@@ -284,6 +286,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
@@ -312,7 +316,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)
@@ -371,6 +375,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
@@ -482,6 +488,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
@@ -544,6 +552,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:
@@ -622,7 +632,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
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)
@@ -677,6 +688,8 @@ class Bookmark(models.Model):
name='%(app_label)s_%(class)s_unique_per_object_and_user'
),
)
verbose_name = _('bookmark')
verbose_name_plural = _('bookmarks')
def __str__(self):
if self.object:
@@ -707,6 +720,8 @@ class ConfigRevision(models.Model):
class Meta:
ordering = ['-created']
verbose_name = _('config revision')
verbose_name_plural = _('config revisions')
def __str__(self):
return f'Config revision #{self.pk} ({self.created})'

View File

@@ -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')

View File

@@ -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')

View File

@@ -51,6 +51,8 @@ class CachedValue(models.Model):
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}'

View File

@@ -41,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})'
@@ -89,6 +91,8 @@ class StagedChange(ChangeLoggedModel):
class Meta:
ordering = ('pk',)
verbose_name = _('staged change')
verbose_name_plural = _('staged changes')
def __str__(self):
action = self.get_action_display()

View File

@@ -50,6 +50,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = _('tags')
def get_absolute_url(self):
return reverse('extras:tag', args=[self.pk])
@@ -75,3 +77,5 @@ class TaggedItem(GenericTaggedItemBase):
class Meta:
indexes = [models.Index(fields=["content_type", "object_id"])]
verbose_name = _('tagged item')
verbose_name_plural = _('tagged items')

View File

@@ -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
@@ -146,23 +145,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.")

View File

@@ -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.")

View File

@@ -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} <pre>{stacktrace}</pre>")
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()

View File

@@ -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

View File

@@ -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': [
{

View File

@@ -1,6 +1,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage
from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
@@ -18,6 +19,7 @@ from netbox.config import get_config, PARAMS
from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
@@ -89,6 +91,25 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
class CustomFieldChoiceSetView(generic.ObjectView):
queryset = CustomFieldChoiceSet.objects.all()
def get_extra_context(self, request, instance):
# Paginate choices list
per_page = get_paginate_count(request)
try:
page_number = request.GET.get('page', 1)
except ValueError:
page_number = 1
paginator = EnhancedPaginator(instance.choices, per_page)
try:
choices = paginator.page(page_number)
except EmptyPage:
choices = paginator.page(paginator.num_pages)
return {
'paginator': paginator,
'choices': choices,
}
@register_model_view(CustomFieldChoiceSet, 'edit')
class CustomFieldChoiceSetEditView(generic.ObjectEditView):

View File

@@ -1,7 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import F
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema
@@ -16,6 +14,7 @@ from circuits.models import Provider
from dcim.models import Site
from ipam import filtersets
from ipam.models import *
from ipam.models import L2VPN, L2VPNTermination
from ipam.utils import get_next_available_prefix
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin
@@ -24,7 +23,6 @@ from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from . import serializers
from ipam.models import L2VPN, L2VPNTermination
class IPAMRootView(APIRootView):
@@ -346,7 +344,11 @@ class AvailableASNsView(AvailableObjectsView):
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)})
@extend_schema(
methods=["post"],
responses={201: serializers.ASNSerializer(many=True)},
request=serializers.ASNSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)
@@ -395,7 +397,11 @@ class AvailablePrefixesView(AvailableObjectsView):
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
@extend_schema(
methods=["post"],
responses={201: serializers.PrefixSerializer(many=True)},
request=serializers.PrefixSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)
@@ -435,7 +441,11 @@ class AvailableIPAddressesView(AvailableObjectsView):
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
@extend_schema(
methods=["post"],
responses={201: serializers.IPAddressSerializer(many=True)},
request=serializers.IPAddressSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)
@@ -482,6 +492,10 @@ class AvailableVLANsView(AvailableObjectsView):
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
@extend_schema(
methods=["post"],
responses={201: serializers.VLANSerializer(many=True)},
request=serializers.VLANSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)

View File

@@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='_assigned_to_interface',
label=_('Is assigned to an interface'),
)
assigned = django_filters.BooleanFilter(
method='_assigned',
label=_('Is assigned'),
)
status = django_filters.MultipleChoiceFilter(
choices=IPAddressStatusChoices,
null_value=None
@@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
assigned_object_id__isnull=False
)
def _assigned(self, queryset, name, value):
if value:
return queryset.exclude(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
else:
return queryset.filter(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
class FHRPGroupFilterSet(NetBoxModelFilterSet):
protocol = django_filters.MultipleChoiceFilter(

View File

@@ -1,7 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Site
@@ -10,7 +9,9 @@ from ipam.constants import *
from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
)
from virtualization.models import VirtualMachine, VMInterface
__all__ = (
@@ -42,10 +43,25 @@ class VRFImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Assigned tenant')
)
import_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Import route targets')
)
export_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Export route targets')
)
class Meta:
model = VRF
fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
fields = (
'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
'tags',
)
class RouteTargetImportForm(NetBoxModelImportForm):

View File

@@ -256,7 +256,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attriubtes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
(_('Attributes'), ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
family = forms.ChoiceField(

View File

@@ -48,8 +48,8 @@ class ASNRange(OrganizationalModel):
class Meta:
ordering = ('name',)
verbose_name = 'ASN range'
verbose_name_plural = 'ASN ranges'
verbose_name = _('ASN range')
verbose_name_plural = _('ASN ranges')
def __str__(self):
return f'{self.name} ({self.range_as_string()})'
@@ -122,8 +122,8 @@ class ASN(PrimaryModel):
class Meta:
ordering = ['asn']
verbose_name = 'ASN'
verbose_name_plural = 'ASNs'
verbose_name = _('ASN')
verbose_name_plural = _('ASNs')
def __str__(self):
return f'AS{self.asn_with_asdot}'

View File

@@ -54,7 +54,8 @@ class FHRPGroup(PrimaryModel):
class Meta:
ordering = ['protocol', 'group_id', 'pk']
verbose_name = 'FHRP group'
verbose_name = _('FHRP group')
verbose_name_plural = _('FHRP groups')
def __str__(self):
name = ''
@@ -108,6 +109,7 @@ class FHRPGroupAssignment(ChangeLoggedModel):
),
)
verbose_name = _('FHRP group assignment')
verbose_name_plural = _('FHRP group assignments')
def __str__(self):
return f'{self.interface}: {self.group} ({self.priority})'

View File

@@ -111,6 +111,8 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
class Meta:
ordering = ('prefix', 'pk') # prefix may be non-unique
verbose_name = _('aggregate')
verbose_name_plural = _('aggregates')
def __str__(self):
return str(self.prefix)
@@ -188,6 +190,8 @@ class Role(OrganizationalModel):
class Meta:
ordering = ('weight', 'name')
verbose_name = _('role')
verbose_name_plural = _('roles')
def __str__(self):
return self.name
@@ -279,7 +283,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
class Meta:
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
verbose_name_plural = 'prefixes'
verbose_name = _('prefix')
verbose_name_plural = _('prefixes')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -532,8 +537,8 @@ class IPRange(PrimaryModel):
class Meta:
ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk') # (vrf, start_address) may be non-unique
verbose_name = 'IP range'
verbose_name_plural = 'IP ranges'
verbose_name = _('IP range')
verbose_name_plural = _('IP ranges')
def __str__(self):
return self.name
@@ -783,8 +788,8 @@ class IPAddress(PrimaryModel):
indexes = [
models.Index(Cast(Host('address'), output_field=IPAddressField()), name='ipam_ipaddress_host'),
]
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
verbose_name = _('IP address')
verbose_name_plural = _('IP addresses')
def __str__(self):
return str(self.address)

View File

@@ -1,4 +1,4 @@
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS
from netbox.models import NetBoxModel, PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = (
'L2VPN',
@@ -16,7 +17,7 @@ __all__ = (
)
class L2VPN(PrimaryModel):
class L2VPN(ContactsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
@@ -54,15 +55,13 @@ class L2VPN(PrimaryModel):
blank=True,
null=True
)
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
clone_fields = ('type',)
class Meta:
ordering = ('name', 'identifier')
verbose_name = 'L2VPN'
verbose_name = _('L2VPN')
verbose_name_plural = _('L2VPNs')
def __str__(self):
if self.identifier:
@@ -105,13 +104,14 @@ class L2VPNTermination(NetBoxModel):
class Meta:
ordering = ('l2vpn',)
verbose_name = 'L2VPN termination'
constraints = (
models.UniqueConstraint(
fields=('assigned_object_type', 'assigned_object_id'),
name='ipam_l2vpntermination_assigned_object'
),
)
verbose_name = _('L2VPN termination')
verbose_name_plural = _('L2VPN terminations')
def __str__(self):
if self.pk is not None:

View File

@@ -56,6 +56,8 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
class Meta:
ordering = ('name',)
verbose_name = _('service template')
verbose_name_plural = _('service templates')
def get_absolute_url(self):
return reverse('ipam:servicetemplate', args=[self.pk])
@@ -97,6 +99,8 @@ class Service(ServiceBase, PrimaryModel):
class Meta:
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
verbose_name = _('service')
verbose_name_plural = _('services')
def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk])

View File

@@ -79,8 +79,8 @@ class VLANGroup(OrganizationalModel):
name='%(app_label)s_%(class)s_unique_scope_slug'
),
)
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
verbose_name = _('VLAN group')
verbose_name_plural = _('VLAN groups')
def get_absolute_url(self):
return reverse('ipam:vlangroup', args=[self.pk])
@@ -204,8 +204,8 @@ class VLAN(PrimaryModel):
name='%(app_label)s_%(class)s_unique_group_name'
),
)
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
verbose_name = _('VLAN')
verbose_name_plural = _('VLANs')
def __str__(self):
return f'{self.name} ({self.vid})'

View File

@@ -59,8 +59,8 @@ class VRF(PrimaryModel):
class Meta:
ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
verbose_name = _('VRF')
verbose_name_plural = _('VRFs')
def __str__(self):
if self.rd:
@@ -91,6 +91,8 @@ class RouteTarget(PrimaryModel):
class Meta:
ordering = ['name']
verbose_name = _('route target')
verbose_name_plural = _('route targets')
def __str__(self):
return self.name

View File

@@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned(self):
params = {'assigned': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'assigned': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

View File

@@ -216,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
template_name = 'ipam/asnrange/asns.html'
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -816,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView):
table = None
if form.is_valid():
addresses = self.queryset.prefetch_related('vrf', 'tenant')
# Limit to 100 results
addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
@@ -866,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/ipaddress/ip_addresses.html'
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -963,7 +962,6 @@ class FHRPGroupView(generic.ObjectView):
queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance):
# Get assigned interfaces
members_table = tables.FHRPGroupAssignmentTable(
data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
@@ -1077,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'ipam/vlan/interfaces.html'
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1095,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'ipam/vlan/vminterfaces.html'
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),

View File

@@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rq.worker import Worker
from extras.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -61,19 +62,11 @@ class StatusView(APIView):
installed_apps[app_config.name] = version
installed_apps = {k: v for k, v in sorted(installed_apps.items())}
# Gather installed plugins
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)
plugins = {k: v for k, v in sorted(plugins.items())}
return Response({
'django-version': DJANGO_VERSION,
'installed-apps': installed_apps,
'netbox-version': settings.VERSION,
'plugins': plugins,
'plugins': get_installed_plugins(),
'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')),
})

View File

@@ -88,7 +88,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).filter(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
ui_visibility__in=[
CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET,
]
)
def _get_form_field(self, customfield):
@@ -127,6 +130,11 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
self.fields['pk'].queryset = self.model.objects.all()
# Restrict tag fields by model
ct = ContentType.objects.get_for_model(self.model)
self.fields['add_tags'].widget.add_query_param('for_object_type_id', ct.pk)
self.fields['remove_tags'].widget.add_query_param('for_object_type_id', ct.pk)
self._extend_nullable_fields()
def _get_form_field(self, customfield):

View File

@@ -22,6 +22,7 @@ __all__ = (
class NetBoxFeatureSet(
BookmarksMixin,
ChangeLoggingMixin,
CloningMixin,
CustomFieldsMixin,
CustomLinksMixin,
CustomValidationMixin,
@@ -53,7 +54,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin
abstract = True
class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
class NetBoxModel(NetBoxFeatureSet, models.Model):
"""
Base model for most object types. Suitable for use by plugins.
"""
@@ -90,6 +91,10 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
})
#
# NetBox internal base models
#
class PrimaryModel(NetBoxModel):
"""
Primary models represent real objects within the infrastructure being modeled.
@@ -108,7 +113,7 @@ class PrimaryModel(NetBoxModel):
abstract = True
class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel):
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
"""
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using MPTT. Within each parent, each child instance must have a unique name.

View File

@@ -25,10 +25,12 @@ __all__ = (
'BookmarksMixin',
'ChangeLoggingMixin',
'CloningMixin',
'ContactsMixin',
'CustomFieldsMixin',
'CustomLinksMixin',
'CustomValidationMixin',
'ExportTemplatesMixin',
'ImageAttachmentsMixin',
'JobsMixin',
'JournalingMixin',
'SyncedDataMixin',
@@ -307,6 +309,30 @@ class ExportTemplatesMixin(models.Model):
abstract = True
class ImageAttachmentsMixin(models.Model):
"""
Enables the assignments of ImageAttachments.
"""
images = GenericRelation(
to='extras.ImageAttachment'
)
class Meta:
abstract = True
class ContactsMixin(models.Model):
"""
Enables the assignments of Contacts (via ContactAssignment).
"""
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
class Meta:
abstract = True
class BookmarksMixin(models.Model):
"""
Enables support for user bookmarks.
@@ -465,6 +491,19 @@ class SyncedDataMixin(models.Model):
return ret
def delete(self, *args, **kwargs):
from core.models import AutoSyncRecord
# Delete AutoSyncRecord
content_type = ContentType.objects.get_for_model(self)
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
).delete()
return super().delete(*args, **kwargs)
def resolve_data_file(self):
"""
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
@@ -499,11 +538,20 @@ class SyncedDataMixin(models.Model):
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
#
# Feature registration
#
FEATURES_MAP = {
'bookmarks': BookmarksMixin,
'change_logging': ChangeLoggingMixin,
'cloning': CloningMixin,
'contacts': ContactsMixin,
'custom_fields': CustomFieldsMixin,
'custom_links': CustomLinksMixin,
'custom_validation': CustomValidationMixin,
'export_templates': ExportTemplatesMixin,
'image_attachments': ImageAttachmentsMixin,
'jobs': JobsMixin,
'journaling': JournalingMixin,
'synced_data': SyncedDataMixin,
@@ -518,12 +566,13 @@ registry['model_features'].update({
@receiver(class_prepared)
def _register_features(sender, **kwargs):
# Record each applicable feature for the model in the registry
features = {
feature for feature, cls in FEATURES_MAP.items() if issubclass(sender, cls)
}
register_features(sender, features)
# Feature view registration
# Register applicable feature views for the model
if issubclass(sender, JournalingMixin):
register_model_view(
sender,

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6-beta1'
VERSION = '3.6-beta2'
# Hostname
HOSTNAME = platform.node()
@@ -474,8 +474,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
TEST_RUNNER = "django_rich.test.RichRunner"
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
EXEMPT_EXCLUDE_MODELS = (
@@ -712,6 +710,10 @@ RQ_QUEUES.update({
# Localization
#
LOCALE_PATHS = (
BASE_DIR + '/translations',
)
if not ENABLE_LOCALIZATION:
USE_I18N = False
USE_L10N = False

View File

@@ -511,9 +511,9 @@ class CustomLinkColumn(tables.Column):
"""
def __init__(self, customlink, *args, **kwargs):
self.customlink = customlink
kwargs['accessor'] = Accessor('pk')
if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = customlink.name
kwargs.setdefault('accessor', Accessor('pk'))
kwargs.setdefault('orderable', False)
kwargs.setdefault('verbose_name', customlink.name)
super().__init__(*args, **kwargs)

View File

@@ -54,7 +54,7 @@ class BaseTable(tables.Table):
# 3. Meta.fields
selected_columns = None
if user is not None and not isinstance(user, AnonymousUser):
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
selected_columns = user.config.get(f"tables.{self.name}.columns")
if not selected_columns:
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
@@ -113,6 +113,10 @@ class BaseTable(tables.Table):
columns.append((name, column.verbose_name))
return columns
@property
def name(self):
return self.__class__.__name__
@property
def available_columns(self):
return self._get_columns(visible=False)
@@ -138,17 +142,16 @@ class BaseTable(tables.Table):
"""
# Save ordering preference
if request.user.is_authenticated:
table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
else:
# If the ordering has been set to none (empty), clear any existing preference.
request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering

View File

@@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
from sentry_sdk import capture_message
from extras.plugins.utils import get_installed_plugins
__all__ = (
'handler_404',
'handler_500',
@@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
'exception': str(type_),
'netbox_version': settings.VERSION,
'python_version': platform.python_version(),
'plugins': get_installed_plugins(),
}))

View File

@@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
return render(request, self.get_template_name(), {
'object': instance,
'child_model': self.child_model,
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table,
'table_config': f'{table.name}_config',
'actions': actions,
'tab': self.tab,
'return_url': request.get_full_path(),
**self.get_extra_context(request, instance),
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import { getElement, getElements, findFirstAdjacent } from '../util';
import { getElements, findFirstAdjacent } from '../util';
/**
* If any PK checkbox is checked, uncheck the select all table checkbox and the select all
@@ -63,29 +63,6 @@ function handleSelectAllToggle(event: Event): void {
}
}
/**
* Synchronize the select all confirmation checkbox state with the select all confirmation button
* disabled state. If the select all confirmation checkbox is checked, the buttons should be
* enabled. If not, the buttons should be disabled.
*
* @param event Change Event
*/
function handleSelectAll(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
const selectAllBox = getElement<HTMLDivElement>('select-all-box');
if (selectAllBox !== null) {
for (const button of selectAllBox.querySelectorAll<HTMLButtonElement>(
'button[type="submit"]',
)) {
if (target.checked) {
button.disabled = false;
} else {
button.disabled = true;
}
}
}
}
/**
* Initialize table select all elements.
*/
@@ -98,9 +75,4 @@ export function initSelectAll(): void {
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
element.addEventListener('change', handlePkCheck);
}
const selectAll = getElement<HTMLInputElement>('select-all');
if (selectAll !== null) {
selectAll.addEventListener('change', handleSelectAll);
}
}

View File

@@ -31,7 +31,10 @@
{{ error }}
{% trans "Python version" %}: {{ python_version }}
{% trans "NetBox version" %}: {{ netbox_version }}</pre>
{% trans "NetBox version" %}: {{ netbox_version }}
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
</pre>
<p>
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
</p>

View File

@@ -54,7 +54,12 @@
</tr>
<tr>
<th scope="row">{% trans "Scheduled" %}</th>
<td>{{ object.scheduled|annotated_date|placeholder }}{% if object.interval %} ({% blocktrans %}every {{ object.interval }} seconds{% endblocktrans %}){% endif %}</td>
<td>
{{ object.scheduled|annotated_date|placeholder }}
{% if object.interval %}
({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %})
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Started" %}</th>

View File

@@ -5,7 +5,11 @@
{% block title %}{% trans "Disconnect" %} {{ obj_type_plural|bettertitle }}{% endblock %}
{% block message %}
<p>{% blocktrans %}Are you sure you want to disconnect these {{ selected_objects|length }} {{ obj_type_plural }}?{% endblocktrans %}</p>
<p>
{% blocktrans with count=selected_objects|length %}
Are you sure you want to disconnect these {{ count }} {{ obj_type_plural }}?
{% endblocktrans %}
</p>
<ul>
{% for obj in selected_objects %}
<li>{{ obj }}</li>

View File

@@ -2,7 +2,11 @@
{% load helpers %}
{% load i18n %}
{% block title %}{% blocktrans %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblocktrans %}{% endblock %}
{% block title %}
{% blocktrans with object_type=object|meta:"verbose_name"|bettertitle %}
Cable Trace for {{ object_type }} {{ object }}
{% endblocktrans %}
{% endblock %}
{% block content %}
<div class="row">

View File

@@ -1,9 +0,0 @@
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{% blocktrans %}Delete console port {{ consoleport }}?{% endblocktrans %}{% endblock %}
{% block message %}
<p>{% blocktrans %}Are you sure you want to delete this console port from <strong>{{ consoleport.device }}</strong>?{% endblocktrans %}</p>
{% endblock %}

View File

@@ -1,9 +0,0 @@
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{% blocktrans %}Delete console server port {{ consoleserverport }}?{% endblocktrans %}{% endblock %}
{% block message %}
<p>{% blocktrans %}Are you sure you want to delete this console server port from <strong>{{ consoleserverport.device }}</strong>?{% endblocktrans %}</p>
{% endblock %}

View File

@@ -295,7 +295,9 @@
</tr>
{% for leg in utilization.legs %}
<tr>
<td style="padding-left: 20px">{% blocktrans %}Leg {{ leg.name }}{% endblocktrans %}</td>
<td style="padding-left: 20px">
{% trans "Leg" context "Leg of a power feed" %} {{ leg }}
</td>
<td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</td>
{% if powerfeed.available_power %}

View File

@@ -0,0 +1,15 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% block bulk_edit_controls %}
{{ block.super }}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_consoleport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Port" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_consoleserverport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleserverport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,51 +1,14 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_devicebay %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_devicebay %}
<div class="bulk-button-group">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
</a>
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_frontport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add front ports" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_frontport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Front Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,67 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit"
formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename"
formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete"
formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect"
formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_interface %}
<div class="bulk-button-group">
<a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
</a>
</div>
<div class="bulk-button-group">
<a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% endblock bulk_extra_controls %}

View File

@@ -1,51 +1,14 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:inventoryitem_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_inventoryitem %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_inventoryitem %}
<div class="bulk-button-group">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
</a>
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,47 +1,14 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:modulebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_modulebay %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_modulebay %}
<div class="bulk-button-group">
<a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
</a>
<a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
</a>
</div>
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_poweroutlet %}
<div class="bulk-button-group">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_poweroutlet %}
<div class="bulk-button-group">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_powerport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_powerport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,58 +1,28 @@
{% extends 'dcim/device/base.html' %}
{% load render_table from django_tables2 %}
{% load static %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
</div>
{% if perms.dcim.add_rearport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add rear ports" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_rearport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Rear Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -2,8 +2,16 @@
{% load form_helpers %}
{% load i18n %}
{% block title %}{% blocktrans %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblocktrans %}{% endblock %}
{% block title %}
{% blocktrans with device=device_bay.installed_device %}
Remove {{ device }} from {{ device_bay }}?
{% endblocktrans %}
{% endblock %}
{% block message %}
<p>{% blocktrans %}Are you sure you want to remove <strong>{{ device_bay.installed_device }}</strong> from <strong>{{ device_bay }}</strong>?{% endblocktrans %}</p>
<p>
{% blocktrans with device=device_bay.installed_device %}
Are you sure you want to remove <strong>{{ device }}</strong> from <strong>{{ device_bay }}</strong>?
{% endblocktrans %}
</p>
{% endblock %}

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